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
5 changes: 5 additions & 0 deletions .changeset/scroll-restoration-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Fix hash scrolling with `resetScroll={false}`
21 changes: 21 additions & 0 deletions e2e/react-start/scroll-restoration/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as testsWithSearchRouteImport } from './routes/(tests)/with-search'
import { Route as testsWithLoaderRouteImport } from './routes/(tests)/with-loader'
import { Route as testsSsrScrollKeyRouteImport } from './routes/(tests)/ssr-scroll-key'
import { Route as testsResetScrollFalseCRouteImport } from './routes/(tests)/reset-scroll-false-c'
import { Route as testsResetScrollFalseBRouteImport } from './routes/(tests)/reset-scroll-false-b'
import { Route as testsResetScrollFalseARouteImport } from './routes/(tests)/reset-scroll-false-a'
Expand All @@ -38,6 +39,11 @@ const testsWithLoaderRoute = testsWithLoaderRouteImport.update({
path: '/with-loader',
getParentRoute: () => rootRouteImport,
} as any)
const testsSsrScrollKeyRoute = testsSsrScrollKeyRouteImport.update({
id: '/(tests)/ssr-scroll-key',
path: '/ssr-scroll-key',
getParentRoute: () => rootRouteImport,
} as any)
const testsResetScrollFalseCRoute = testsResetScrollFalseCRouteImport.update({
id: '/(tests)/reset-scroll-false-c',
path: '/reset-scroll-false-c',
Expand Down Expand Up @@ -103,6 +109,7 @@ export interface FileRoutesByFullPath {
'/reset-scroll-false-a': typeof testsResetScrollFalseARoute
'/reset-scroll-false-b': typeof testsResetScrollFalseBRoute
'/reset-scroll-false-c': typeof testsResetScrollFalseCRoute
'/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/with-loader': typeof testsWithLoaderRoute
'/with-search': typeof testsWithSearchRoute
}
Expand All @@ -118,6 +125,7 @@ export interface FileRoutesByTo {
'/reset-scroll-false-a': typeof testsResetScrollFalseARoute
'/reset-scroll-false-b': typeof testsResetScrollFalseBRoute
'/reset-scroll-false-c': typeof testsResetScrollFalseCRoute
'/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/with-loader': typeof testsWithLoaderRoute
'/with-search': typeof testsWithSearchRoute
}
Expand All @@ -134,6 +142,7 @@ export interface FileRoutesById {
'/(tests)/reset-scroll-false-a': typeof testsResetScrollFalseARoute
'/(tests)/reset-scroll-false-b': typeof testsResetScrollFalseBRoute
'/(tests)/reset-scroll-false-c': typeof testsResetScrollFalseCRoute
'/(tests)/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/(tests)/with-loader': typeof testsWithLoaderRoute
'/(tests)/with-search': typeof testsWithSearchRoute
}
Expand All @@ -151,6 +160,7 @@ export interface FileRouteTypes {
| '/reset-scroll-false-a'
| '/reset-scroll-false-b'
| '/reset-scroll-false-c'
| '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
fileRoutesByTo: FileRoutesByTo
Expand All @@ -166,6 +176,7 @@ export interface FileRouteTypes {
| '/reset-scroll-false-a'
| '/reset-scroll-false-b'
| '/reset-scroll-false-c'
| '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
id:
Expand All @@ -181,6 +192,7 @@ export interface FileRouteTypes {
| '/(tests)/reset-scroll-false-a'
| '/(tests)/reset-scroll-false-b'
| '/(tests)/reset-scroll-false-c'
| '/(tests)/ssr-scroll-key'
| '/(tests)/with-loader'
| '/(tests)/with-search'
fileRoutesById: FileRoutesById
Expand All @@ -197,6 +209,7 @@ export interface RootRouteChildren {
testsResetScrollFalseARoute: typeof testsResetScrollFalseARoute
testsResetScrollFalseBRoute: typeof testsResetScrollFalseBRoute
testsResetScrollFalseCRoute: typeof testsResetScrollFalseCRoute
testsSsrScrollKeyRoute: typeof testsSsrScrollKeyRoute
testsWithLoaderRoute: typeof testsWithLoaderRoute
testsWithSearchRoute: typeof testsWithSearchRoute
}
Expand Down Expand Up @@ -224,6 +237,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof testsWithLoaderRouteImport
parentRoute: typeof rootRouteImport
}
'/(tests)/ssr-scroll-key': {
id: '/(tests)/ssr-scroll-key'
path: '/ssr-scroll-key'
fullPath: '/ssr-scroll-key'
preLoaderRoute: typeof testsSsrScrollKeyRouteImport
parentRoute: typeof rootRouteImport
}
'/(tests)/reset-scroll-false-c': {
id: '/(tests)/reset-scroll-false-c'
path: '/reset-scroll-false-c'
Expand Down Expand Up @@ -309,6 +329,7 @@ const rootRouteChildren: RootRouteChildren = {
testsResetScrollFalseARoute: testsResetScrollFalseARoute,
testsResetScrollFalseBRoute: testsResetScrollFalseBRoute,
testsResetScrollFalseCRoute: testsResetScrollFalseCRoute,
testsSsrScrollKeyRoute: testsSsrScrollKeyRoute,
testsWithLoaderRoute: testsWithLoaderRoute,
testsWithSearchRoute: testsWithSearchRoute,
}
Expand Down
4 changes: 4 additions & 0 deletions e2e/react-start/scroll-restoration/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export function getRouter() {
scrollRestoration: true,
scrollToTopSelectors: ['[data-scroll-restoration-id="carry-over-reset"]'],
getScrollRestorationKey: (location) => {
if (location.pathname === '/ssr-scroll-key') {
return 'ssr-scroll-key'
}

if (location.pathname === '/hash-scroll-repro') {
return location.pathname
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ function Component() {
>
#one
</Link>
<Link
to="/hash-scroll-repro"
hash="four"
className="rounded border px-3 py-2"
data-testid="hash-scroll-section-four-no-reset-link"
resetScroll={false}
>
#four no reset
</Link>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
import * as React from 'react'
import { ScrollBlock } from '../-components/scroll-block'

export const Route = createFileRoute('/(tests)/ssr-scroll-key')({
component: Component,
})

function Component() {
return (
<div className="p-2">
<h3>ssr-scroll-key</h3>
<ScrollBlock />
</div>
)
}
40 changes: 40 additions & 0 deletions e2e/react-start/scroll-restoration/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,46 @@ test('Smoke - Renders home', async ({ page }) => {
).toBeVisible()
})

test('restores window scroll after force reload of a navigated route', async ({
page,
}) => {
await page.goto('/')
await page.getByRole('link', { name: '/reset-scroll-false-a' }).click()
await expect(
page.getByRole('heading', { name: 'reset-scroll-false-a' }),
).toBeVisible()
await expect
.poll(async () =>
page.evaluate(() => document.documentElement.scrollHeight > innerHeight),
)
.toBe(true)

const scrollY = await page.evaluate(async () => {
window.scrollTo(0, 500)
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
return window.scrollY
})

expect(scrollY).toBeGreaterThan(0)

await page.reload()
await expect(
page.getByRole('heading', { name: 'reset-scroll-false-a' }),
).toBeVisible()
await expect
.poll(async () => page.evaluate(() => window.scrollY))
.toBe(scrollY)
})

test('initial hard navigation to a hash scrolls to the hash target', async ({
page,
}) => {
await page.goto('/hash-scroll-repro#four')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('hash-scroll-section-four')).toBeInViewport()
await expect(page.getByTestId('hash-scroll-section-one')).not.toBeInViewport()
})

// Test for scroll related stuff
;[
linkOptions({ to: '/normal-page' }),
Expand Down
16 changes: 16 additions & 0 deletions e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,19 @@ test('hash navigation still runs when only nested scroll entries restore', async
})
.toBe(nestedScrollTop)
})

test('hash navigation scrolls when resetScroll is false', async ({ page }) => {
await page.goto('/hash-scroll-repro#one')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport()

const scrollYBeforeHashNavigation = await page.evaluate(() => window.scrollY)

await page.getByTestId('hash-scroll-section-four-no-reset-link').click()

await expect(page).toHaveURL(/#four$/)
await expect(page.getByTestId('hash-scroll-section-four')).toBeInViewport()
await expect
.poll(async () => page.evaluate(() => window.scrollY))
.toBeGreaterThan(scrollYBeforeHashNavigation)
})
41 changes: 41 additions & 0 deletions e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test'

const storageKey = 'tsr-scroll-restoration-v1_3'

test('SSR scroll restoration uses a custom restoration key', async ({
page,
}) => {
const customKeyScrollY = 650
const historyKeyScrollY = 80

await page.goto('/')
await page.evaluate(
({ customKeyScrollY, historyKeyScrollY, storageKey }) => {
window.sessionStorage.setItem(
storageKey,
JSON.stringify({
'ssr-scroll-key': {
window: { scrollX: 0, scrollY: customKeyScrollY },
},
'history-key': {
window: { scrollX: 0, scrollY: historyKeyScrollY },
},
}),
)
},
{ customKeyScrollY, historyKeyScrollY, storageKey },
)

await page.addInitScript(() => {
window.history.replaceState({ __TSR_key: 'history-key' }, '')
})
await page.route(/\/assets\/.*\.js$/, (route) => route.abort())

await page.goto('/ssr-scroll-key', { waitUntil: 'domcontentloaded' })
await expect(
page.getByRole('heading', { name: 'ssr-scroll-key' }),
).toBeVisible()
await expect
.poll(async () => page.evaluate(() => window.scrollY))
.toBe(customKeyScrollY)
})
57 changes: 19 additions & 38 deletions packages/router-core/src/scroll-restoration-inline.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,43 @@
export default function (options: { storageKey: string; key?: string }) {
export default function (storageKey: string, key?: string) {
let byKey

try {
byKey = JSON.parse(sessionStorage.getItem(options.storageKey) || '{}')
} catch (error) {
console.error(error)
byKey = JSON.parse(sessionStorage.getItem(storageKey) || '{}')
} catch {
return
}

const resolvedKey = options.key || window.history.state?.__TSR_key
const elementEntries = resolvedKey ? byKey[resolvedKey] : undefined
const elementEntries = byKey?.[key || history.state?.__TSR_key]
let windowRestored = false

if (elementEntries && typeof elementEntries === 'object') {
for (const elementSelector in elementEntries) {
const entry = elementEntries[elementSelector]

if (!entry || typeof entry !== 'object') {
continue
}

const scrollX = entry.scrollX
const scrollY = entry.scrollY

if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) {
continue
}
for (const elementSelector in elementEntries) {
const entry = elementEntries[elementSelector]
const scrollX = entry?.scrollX
const scrollY = entry?.scrollY

if (Number.isFinite(scrollX) && Number.isFinite(scrollY)) {
if (elementSelector === 'window') {
window.scrollTo({
top: scrollY,
left: scrollX,
})
scrollTo(scrollX, scrollY)
windowRestored = true
} else if (elementSelector) {
let element

try {
element = document.querySelector(elementSelector)
} catch {
continue
}

if (element) {
element.scrollLeft = scrollX
element.scrollTop = scrollY
}
const element = document.querySelector(elementSelector)
if (element) {
element.scrollLeft = scrollX
element.scrollTop = scrollY
}
} catch {}
}
}
}

if (windowRestored) return

const hash = window.location.hash.split('#', 2)[1]
const hash = location.hash.slice(1)

if (hash) {
const hashScrollIntoViewOptions =
window.history.state?.__hashScrollIntoViewOptions ?? true
history.state?.__hashScrollIntoViewOptions ?? true

if (hashScrollIntoViewOptions) {
const el = document.getElementById(hash)
Expand All @@ -68,5 +49,5 @@ export default function (options: { storageKey: string; key?: string }) {
return
}

window.scrollTo({ top: 0, left: 0 })
scrollTo(0, 0)
}
22 changes: 5 additions & 17 deletions packages/router-core/src/scroll-restoration-script/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,16 @@ import {
import { escapeHtml } from '../utils'
import type { AnyRouter } from '../router'

type InlineScrollRestorationScriptOptions = {
storageKey: string
key?: string
}

const defaultInlineScrollRestorationScript = `(${minifiedScrollRestorationScript})(${escapeHtml(
JSON.stringify({
storageKey,
} satisfies InlineScrollRestorationScriptOptions),
JSON.stringify(storageKey),
)})`

function getScrollRestorationScript(
options: InlineScrollRestorationScriptOptions,
) {
if (options.storageKey === storageKey && options.key === undefined) {
function getScrollRestorationScript(key?: string) {
if (key === undefined) {
return defaultInlineScrollRestorationScript
}

return `(${minifiedScrollRestorationScript})(${escapeHtml(JSON.stringify(options))})`
return `(${minifiedScrollRestorationScript})(${escapeHtml(JSON.stringify(storageKey))},${escapeHtml(JSON.stringify(key))})`
}

export function getScrollRestorationScriptForRouter(router: AnyRouter) {
Expand All @@ -48,8 +39,5 @@ export function getScrollRestorationScriptForRouter(router: AnyRouter) {
return defaultInlineScrollRestorationScript
}

return getScrollRestorationScript({
storageKey,
key: userKey,
})
return getScrollRestorationScript(userKey)
}
Loading
Loading