michaeljscript / next-restore-scroll-position

Restore scroll position using NextJS
15 stars 2 forks source link

UPGRADE - Support multiple pages, iOS Snapshot, overflow child element instead of window. #3

Open LeakedDave opened 3 months ago

LeakedDave commented 3 months ago

I updated the scripts a bit. I'm using window.history.state.key to track positions, so it supports going to the same page throughout history and always restoring the correct scroll position. This also fixes iOS Snapshots, since I don't set window.scrollRestoration to manual.

I also allow you to set a selector for an element to restore scroll to, for my website I have an "overflow-y-auto" child element that I need scrollRestoration for, so window won't do the trick. Here is my updated, versatile script for 2024 lol.

I also added an option to add a delay if you need to, and I moved router into the options, it will default to useRouter if you don't provide one.

useScrollRestoration.js

import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'

export const saveScrollPos = (key, selector) => {
    let scrollPos = { x: window.scrollX, y: window.scrollY }

    if (selector) {
        const element = document.querySelector(selector)
        scrollPos = { x: element.scrollLeft, y: element.scrollTop }
    }

    sessionStorage.setItem(`scrollPos:${key}`, JSON.stringify(scrollPos))
}

export const restoreScrollPos = (key, selector) => {
    const json = sessionStorage.getItem(`scrollPos:${key}`)
    const scrollPos = json ? JSON.parse(json) : { x: 0, y: 0 }

    if (selector) {
        const element = document.querySelector(selector)
        element.scrollTo(scrollPos.x, scrollPos.y)
    } else {
        window.scrollTo(scrollPos.x, scrollPos.y)
    }
}

export const deleteScrollPos = (key) => {
    sessionStorage.removeItem(`scrollPos:${key}`)
}

export function useScrollRestoration({ router = null, enabled = true, selector = null, delay = null } = {}) {
    const nextRouter = useRouter()
    if (!router) {
        router = nextRouter
    }

    const [key, setKey] = useState(null)

    useEffect(() => {
        setKey(window.history.state.key)
    }, [])

    useEffect(() => {
        if (!enabled) return

        const onBeforeUnload = () => {
            deleteScrollPos(key)
            deleteScrollPos(window.history.state.key)
        }

        const onRouteChangeStart = () => {
            saveScrollPos(key, selector)
        }

        const onRouteChangeComplete = () => {
            setKey(window.history.state.key)

            if (delay != null) {
                setTimeout(() => {
                    restoreScrollPos(window.history.state.key, selector, delay)
                    deleteScrollPos(window.history.state.key)
                }, delay)
            } else {
                restoreScrollPos(window.history.state.key, selector, delay)
                deleteScrollPos(window.history.state.key)
            }
        }

        window.addEventListener('beforeunload', onBeforeUnload)
        router.events.on('routeChangeStart', onRouteChangeStart)
        router.events.on('routeChangeComplete', onRouteChangeComplete)

        return () => {
            window.removeEventListener('beforeunload', onBeforeUnload)
            router.events.off('routeChangeStart', onRouteChangeStart)
            router.events.off('routeChangeComplete', onRouteChangeComplete)
        }
    }, [enabled, key, selector])
}

Default setup: useScrollRestoration()

Scroll child element <main> with overflow: useScrollRestoration(router, { selector: "main" })

Delay Example: useScrollRestoration(router, { delay: 100 })

shawnCaza commented 3 months ago

Adding scroll restoration to a particular element is something I am currently using. Glad to see it added here.

Once criticism I received for the storing x,y position approach is that the user could change the size of the browser. Any thoughts? I stuck with it x,y position as it seems like a bit of an edge case, extra complexity of determining which element should scroll into view, and the possibility that elements re-flowing at different browser sizes might lead to imperfect results anyhow.

LeakedDave commented 3 months ago

You could do a percentage based scroll maybe? Like instead of storing scrollX and scrollY directly, store it as a percentage of the width and height of the window or element that has overflow. Then on restore, convert that percentage to a position.

Also for iOS it's best to do this

        window.history.scrollRestoration = iOS() ? 'auto' : 'manual'

export const iOS = () => {
  return navigator.userAgent.match(/(iPad|iPhone|iPod)/g)
    // iPad on iOS 13 detection
    || (navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1)
}
shawnCaza commented 3 months ago

That's an interesting idea. Might at least get you closer than x,y in situations where page elements re-flow.

Scrolling and navigating around a handful of random sites now and I haven't found any where content re-flows and I'm returned to the same element after a browser resize.