UNPKG

@lonli-lokli/scroll-restoration

Version:

A lightweight, framework-agnostic scroll restoration solution for React applications.

306 lines (305 loc) 10.2 kB
import * as React from "react"; const windowKey = "window"; const delimiter = "___"; const storageKey = "ll-scroll-restoration-v2"; const SCROLL_SAVE_EVENT = "scrollRestorationSave"; const SCROLL_RESTORE_EVENT = "scrollRestorationRestore"; function getCssSelector(el) { const path = []; let current = el; let parent; while ((parent = current.parentNode) && parent.nodeName !== "HTML") { path.unshift( `${current.tagName}:nth-child(${Array.from(parent.children).indexOf(current) + 1})` ); current = parent; } return `${path.join(" > ")}`.toLowerCase(); } function functionalUpdate(updater, value) { return typeof updater === "function" ? updater(value) : updater; } function throttle(func, wait) { let timeout = null; let lastArgs; let lastCallTime = 0; return function(...args) { const now = Date.now(); const remaining = wait - (now - lastCallTime); lastArgs = args; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } lastCallTime = now; func.apply(this, args); } else if (!timeout) { timeout = setTimeout(() => { lastCallTime = Date.now(); timeout = null; if (lastArgs) { func.apply(this, lastArgs); } }, remaining); } }; } const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; let weakScrolledElements = /* @__PURE__ */ new WeakSet(); const createCache = () => { if (typeof window === "undefined" || !window.sessionStorage) { return { state: { cached: {}, next: {} }, // eslint-disable-next-line @typescript-eslint/no-empty-function set: () => { } }; } const state = JSON.parse( window.sessionStorage.getItem(storageKey) || "null" ) || { cached: {}, next: {} }; return { state, set: (updater) => { cache.state = functionalUpdate(updater, cache.state); window.sessionStorage.setItem(storageKey, JSON.stringify(cache.state)); } }; }; const cache = createCache(); const defaultGetKey = (location) => { var _a; return ((_a = location.state) == null ? void 0 : _a.key) || location.href; }; const defaultGetCurrentLocation = () => { if (typeof window === "undefined") { return { href: "", pathname: "", search: "", hash: "" }; } return { href: window.location.href, pathname: window.location.pathname, search: window.location.search, hash: window.location.hash, state: window.history.state }; }; const defaultNavigationListener = (onNavigate) => { if (typeof window === "undefined") return () => { }; let lastLocation = defaultGetCurrentLocation(); let navigationTimeout = null; const checkForUrlChange = () => { const currentLocation = defaultGetCurrentLocation(); if (currentLocation.pathname !== lastLocation.pathname || currentLocation.search !== lastLocation.search || currentLocation.hash !== lastLocation.hash) { const prevLocation = lastLocation; saveCurrentScrollPositions(); lastLocation = currentLocation; onNavigate(prevLocation); } }; const handlePopState = () => { checkForUrlChange(); }; window.addEventListener("popstate", handlePopState); const observer = new MutationObserver(() => { if (navigationTimeout !== null) { window.clearTimeout(navigationTimeout); } navigationTimeout = window.setTimeout(() => { checkForUrlChange(); navigationTimeout = null; }, 0); }); observer.observe( document.querySelector("head > title") || document.documentElement, { subtree: true, childList: true } ); return () => { window.removeEventListener("popstate", handlePopState); observer.disconnect(); if (navigationTimeout !== null) { window.clearTimeout(navigationTimeout); } }; }; function saveCurrentScrollPositions() { if (typeof window !== "undefined") { window.dispatchEvent(new Event(SCROLL_SAVE_EVENT)); } } function restoreScrollPositions() { if (typeof window !== "undefined") { window.dispatchEvent(new Event(SCROLL_RESTORE_EVENT)); } } function useScrollRestoration(options) { const getKey = (options == null ? void 0 : options.getKey) || defaultGetKey; const getCurrentLocation = (options == null ? void 0 : options.getCurrentLocation) || defaultGetCurrentLocation; const navigationListener = (options == null ? void 0 : options.navigationListener) || defaultNavigationListener; const locationRef = React.useRef(getCurrentLocation()); const saveScrollPositions = React.useCallback( throttle((currentLocation) => { if (typeof window === "undefined") return; const locationKey = getKey(currentLocation); for (const elementSelector in cache.state.next) { const entry = cache.state.next[elementSelector]; if (elementSelector === windowKey) { entry.scrollX = window.scrollX || 0; entry.scrollY = window.scrollY || 0; } else if (elementSelector) { const element = document.querySelector(elementSelector); entry.scrollX = (element == null ? void 0 : element.scrollLeft) || 0; entry.scrollY = (element == null ? void 0 : element.scrollTop) || 0; } cache.set((c) => { const next = { ...c.next }; delete next[elementSelector]; return { ...c, next, cached: { ...c.cached, [[locationKey, elementSelector].join(delimiter)]: entry } }; }); } }, 100), [getKey] ); const restoreScrollPositions2 = React.useCallback( throttle((currentLocation) => { if (typeof window === "undefined") return; const locationKey = getKey(currentLocation); let windowRestored = false; for (const cacheKey in cache.state.cached) { const entry = cache.state.cached[cacheKey]; const [key, elementSelector] = cacheKey.split(delimiter); if (key === locationKey) { if (elementSelector === windowKey) { windowRestored = true; window.scrollTo({ top: entry.scrollY, left: entry.scrollX, behavior: options == null ? void 0 : options.scrollBehavior }); } else if (elementSelector) { const element = document.querySelector(elementSelector); if (element) { element.scrollLeft = entry.scrollX; element.scrollTop = entry.scrollY; } } } } if (!windowRestored) { window.scrollTo(0, 0); } cache.set((c) => ({ ...c, next: {} })); weakScrolledElements = /* @__PURE__ */ new WeakSet(); }, 100), [getKey, options == null ? void 0 : options.scrollBehavior] ); const handleNavigation = React.useCallback(() => { locationRef.current = getCurrentLocation(); requestAnimationFrame(() => { requestAnimationFrame(() => { restoreScrollPositions2(locationRef.current); }); }); }, [getCurrentLocation, restoreScrollPositions2]); useIsomorphicLayoutEffect(() => { if (typeof window === "undefined") return; if ("scrollRestoration" in window.history) { window.history.scrollRestoration = "manual"; } const onScroll = (event) => { if (weakScrolledElements.has(event.target)) return; weakScrolledElements.add(event.target); let elementSelector = ""; if (event.target === document || event.target === window) { elementSelector = windowKey; } else { const target = event.target; const attrId = target.getAttribute("data-scroll-restoration-id"); if (attrId) { elementSelector = `[data-scroll-restoration-id="${attrId}"]`; } else { elementSelector = getCssSelector(target); } } if (!cache.state.next[elementSelector]) { cache.set((c) => ({ ...c, next: { ...c.next, [elementSelector]: { scrollX: NaN, scrollY: NaN } } })); } }; const handleScrollSave = () => { saveScrollPositions(locationRef.current); }; const handleScrollRestore = () => { restoreScrollPositions2(locationRef.current); }; document.addEventListener("scroll", onScroll, true); window.addEventListener(SCROLL_SAVE_EVENT, handleScrollSave); window.addEventListener(SCROLL_RESTORE_EVENT, handleScrollRestore); const cleanup = navigationListener(handleNavigation); locationRef.current = getCurrentLocation(); return () => { document.removeEventListener("scroll", onScroll, true); window.removeEventListener(SCROLL_SAVE_EVENT, handleScrollSave); window.removeEventListener(SCROLL_RESTORE_EVENT, handleScrollRestore); cleanup(); }; }, [ options == null ? void 0 : options.getKey, options == null ? void 0 : options.scrollBehavior, navigationListener, handleNavigation, saveScrollPositions, restoreScrollPositions2, getCurrentLocation ]); } function ScrollRestoration(props) { useScrollRestoration(props); return null; } function useElementScrollRestoration(options) { var _a; const getKey = options.getKey || defaultGetKey; const getCurrentLocation = options.getCurrentLocation || defaultGetCurrentLocation; const location = getCurrentLocation(); let elementSelector = ""; if (options.id) { elementSelector = `[data-scroll-restoration-id="${options.id}"]`; } else { const element = (_a = options.getElement) == null ? void 0 : _a.call(options); if (!element) { return; } elementSelector = getCssSelector(element); } const restoreKey = getKey(location); const cacheKey = [restoreKey, elementSelector].join(delimiter); return cache.state.cached[cacheKey]; } export { ScrollRestoration, restoreScrollPositions, saveCurrentScrollPositions, useElementScrollRestoration, useScrollRestoration }; //# sourceMappingURL=index.js.map