358 lines
9.5 KiB
Plaintext
358 lines
9.5 KiB
Plaintext
import { isServer } from '@tanstack/router-core/isServer'
|
|
import { functionalUpdate, isPlainObject } from './utils'
|
|
import type { AnyRouter } from './router'
|
|
import type { ParsedLocation } from './location'
|
|
import type { NonNullableUpdater } from './utils'
|
|
|
|
export type ScrollRestorationEntry = { scrollX: number; scrollY: number }
|
|
|
|
type ScrollRestorationByElement = Record<string, ScrollRestorationEntry>
|
|
|
|
type ScrollRestorationByKey = Record<string, ScrollRestorationByElement>
|
|
|
|
type ScrollRestorationCache = {
|
|
readonly state: ScrollRestorationByKey
|
|
set: (updater: NonNullableUpdater<ScrollRestorationByKey>) => void
|
|
persist: () => void
|
|
}
|
|
|
|
export type ScrollRestorationOptions = {
|
|
getKey?: (location: ParsedLocation) => string
|
|
scrollBehavior?: ScrollToOptions['behavior']
|
|
}
|
|
|
|
function getSafeSessionStorage() {
|
|
try {
|
|
return typeof window !== 'undefined' &&
|
|
typeof window.sessionStorage === 'object'
|
|
? window.sessionStorage
|
|
: undefined
|
|
} catch {
|
|
// silent
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// SessionStorage key used to store scroll positions across navigations.
|
|
export const storageKey = 'tsr-scroll-restoration-v1_3'
|
|
|
|
function createScrollRestorationCache(): ScrollRestorationCache | null {
|
|
const safeSessionStorage = getSafeSessionStorage()
|
|
if (!safeSessionStorage) {
|
|
return null
|
|
}
|
|
|
|
let state: ScrollRestorationByKey = {}
|
|
|
|
try {
|
|
const parsed = JSON.parse(safeSessionStorage.getItem(storageKey) || '{}')
|
|
if (isPlainObject(parsed)) {
|
|
state = parsed as ScrollRestorationByKey
|
|
}
|
|
} catch {
|
|
// ignore invalid session storage payloads
|
|
}
|
|
|
|
const persist = () => {
|
|
try {
|
|
safeSessionStorage.setItem(storageKey, JSON.stringify(state))
|
|
} catch {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.warn(
|
|
'[ts-router] Could not persist scroll restoration state to sessionStorage.',
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
get state() {
|
|
return state
|
|
},
|
|
set: (updater) => {
|
|
state = functionalUpdate(updater, state) || state
|
|
},
|
|
persist,
|
|
}
|
|
}
|
|
|
|
export const scrollRestorationCache = createScrollRestorationCache()
|
|
|
|
/**
|
|
* The default `getKey` function for `useScrollRestoration`.
|
|
* It returns the `key` from the location state or the `href` of the location.
|
|
*
|
|
* The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render.
|
|
*/
|
|
export const defaultGetScrollRestorationKey = (location: ParsedLocation) => {
|
|
return location.state.__TSR_key! || location.href
|
|
}
|
|
|
|
function getCssSelector(el: any): string {
|
|
const path = []
|
|
let parent: HTMLElement
|
|
while ((parent = el.parentNode)) {
|
|
path.push(
|
|
`${el.tagName}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`,
|
|
)
|
|
el = parent
|
|
}
|
|
return `${path.reverse().join(' > ')}`.toLowerCase()
|
|
}
|
|
|
|
export function getElementScrollRestorationEntry(
|
|
router: AnyRouter,
|
|
options: (
|
|
| {
|
|
id: string
|
|
getElement?: () => Window | Element | undefined | null
|
|
}
|
|
| {
|
|
id?: string
|
|
getElement: () => Window | Element | undefined | null
|
|
}
|
|
) & {
|
|
getKey?: (location: ParsedLocation) => string
|
|
},
|
|
): ScrollRestorationEntry | undefined {
|
|
const getKey = options.getKey || defaultGetScrollRestorationKey
|
|
const restoreKey = getKey(router.latestLocation)
|
|
|
|
if (options.id) {
|
|
return scrollRestorationCache?.state[restoreKey]?.[
|
|
`[${scrollRestorationIdAttribute}="${options.id}"]`
|
|
]
|
|
}
|
|
|
|
const element = options.getElement?.()
|
|
if (!element) {
|
|
return
|
|
}
|
|
|
|
return scrollRestorationCache?.state[restoreKey]?.[
|
|
element instanceof Window ? windowScrollTarget : getCssSelector(element)
|
|
]
|
|
}
|
|
|
|
let ignoreScroll = false
|
|
const windowScrollTarget = 'window'
|
|
const scrollRestorationIdAttribute = 'data-scroll-restoration-id'
|
|
type ScrollTarget = typeof windowScrollTarget | Element
|
|
|
|
export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
|
|
if (!scrollRestorationCache && !(isServer ?? router.isServer)) {
|
|
return
|
|
}
|
|
|
|
const cache = scrollRestorationCache
|
|
|
|
const shouldScrollRestoration =
|
|
force ?? router.options.scrollRestoration ?? false
|
|
|
|
if (shouldScrollRestoration) {
|
|
router.isScrollRestoring = true
|
|
}
|
|
|
|
if (
|
|
(isServer ?? router.isServer) ||
|
|
router.isScrollRestorationSetup ||
|
|
!cache
|
|
) {
|
|
return
|
|
}
|
|
|
|
router.isScrollRestorationSetup = true
|
|
ignoreScroll = false
|
|
|
|
const getKey =
|
|
router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
|
|
const trackedScrollEntries = new Map<ScrollTarget, ScrollRestorationEntry>()
|
|
|
|
window.history.scrollRestoration = 'manual'
|
|
|
|
const onScroll = (event: Event) => {
|
|
if (ignoreScroll || !router.isScrollRestoring) {
|
|
return
|
|
}
|
|
|
|
if (event.target === document || event.target === window) {
|
|
trackedScrollEntries.set(windowScrollTarget, {
|
|
scrollX: window.scrollX || 0,
|
|
scrollY: window.scrollY || 0,
|
|
})
|
|
} else {
|
|
const target = event.target as Element
|
|
trackedScrollEntries.set(target, {
|
|
scrollX: target.scrollLeft || 0,
|
|
scrollY: target.scrollTop || 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Snapshot the current page's tracked scroll targets before navigation or unload.
|
|
const snapshotCurrentScrollTargets = (restoreKey?: string) => {
|
|
if (
|
|
!router.isScrollRestoring ||
|
|
!restoreKey ||
|
|
trackedScrollEntries.size === 0 ||
|
|
!cache
|
|
) {
|
|
return
|
|
}
|
|
|
|
const keyEntry = (cache.state[restoreKey] ||=
|
|
{} as ScrollRestorationByElement)
|
|
|
|
for (const [target, position] of trackedScrollEntries) {
|
|
let selector: string | undefined
|
|
|
|
if (target === windowScrollTarget) {
|
|
selector = windowScrollTarget
|
|
} else if (target.isConnected) {
|
|
const attrId = target.getAttribute(scrollRestorationIdAttribute)
|
|
selector = attrId
|
|
? `[${scrollRestorationIdAttribute}="${attrId}"]`
|
|
: getCssSelector(target)
|
|
}
|
|
|
|
if (!selector) {
|
|
continue
|
|
}
|
|
|
|
keyEntry[selector] = position
|
|
}
|
|
}
|
|
|
|
document.addEventListener('scroll', onScroll, true)
|
|
router.subscribe('onBeforeLoad', (event) => {
|
|
snapshotCurrentScrollTargets(
|
|
event.fromLocation ? getKey(event.fromLocation) : undefined,
|
|
)
|
|
trackedScrollEntries.clear()
|
|
})
|
|
window.addEventListener('pagehide', () => {
|
|
snapshotCurrentScrollTargets(
|
|
getKey(
|
|
router.stores.resolvedLocation.get() ?? router.stores.location.get(),
|
|
),
|
|
)
|
|
cache.persist()
|
|
})
|
|
|
|
// Restore destination scroll after the new route has rendered.
|
|
router.subscribe('onRendered', (event) => {
|
|
const cacheKey = getKey(event.toLocation)
|
|
const behavior = router.options.scrollRestorationBehavior
|
|
const scrollToTopSelectors = router.options.scrollToTopSelectors
|
|
trackedScrollEntries.clear()
|
|
|
|
if (!router.resetNextScroll) {
|
|
router.resetNextScroll = true
|
|
return
|
|
}
|
|
|
|
if (
|
|
typeof router.options.scrollRestoration === 'function' &&
|
|
!router.options.scrollRestoration({ location: router.latestLocation })
|
|
) {
|
|
return
|
|
}
|
|
|
|
ignoreScroll = true
|
|
|
|
try {
|
|
const elementEntries = router.isScrollRestoring
|
|
? cache.state[cacheKey]
|
|
: undefined
|
|
let restored = false
|
|
|
|
if (elementEntries) {
|
|
for (const elementSelector in elementEntries) {
|
|
const entry = elementEntries[elementSelector]
|
|
|
|
if (!isPlainObject(entry)) {
|
|
continue
|
|
}
|
|
|
|
const { scrollX, scrollY } = entry as {
|
|
scrollX?: unknown
|
|
scrollY?: unknown
|
|
}
|
|
|
|
if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) {
|
|
continue
|
|
}
|
|
|
|
if (elementSelector === windowScrollTarget) {
|
|
window.scrollTo({
|
|
top: scrollY as number,
|
|
left: scrollX as number,
|
|
behavior,
|
|
})
|
|
restored = true
|
|
} else if (elementSelector) {
|
|
let element
|
|
|
|
try {
|
|
element = document.querySelector(elementSelector)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
if (element) {
|
|
element.scrollLeft = scrollX as number
|
|
element.scrollTop = scrollY as number
|
|
restored = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!restored) {
|
|
const hash = router.history.location.hash.slice(1)
|
|
|
|
if (hash) {
|
|
const hashScrollIntoViewOptions =
|
|
window.history.state?.__hashScrollIntoViewOptions ?? true
|
|
|
|
if (hashScrollIntoViewOptions) {
|
|
const el = document.getElementById(hash)
|
|
if (el) {
|
|
el.scrollIntoView(hashScrollIntoViewOptions)
|
|
}
|
|
}
|
|
} else {
|
|
const scrollOptions = {
|
|
top: 0,
|
|
left: 0,
|
|
behavior,
|
|
}
|
|
|
|
window.scrollTo(scrollOptions)
|
|
if (scrollToTopSelectors) {
|
|
for (const selector of scrollToTopSelectors) {
|
|
if (selector === windowScrollTarget) continue
|
|
const element =
|
|
typeof selector === 'function'
|
|
? selector()
|
|
: document.querySelector(selector)
|
|
if (element) {
|
|
element.scrollTo(scrollOptions)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
ignoreScroll = false
|
|
}
|
|
|
|
if (router.isScrollRestoring) {
|
|
cache.set((state) => {
|
|
state[cacheKey] ||= {} as ScrollRestorationByElement
|
|
return state
|
|
})
|
|
}
|
|
})
|
|
}
|