@lonli-lokli/scroll-restoration
Version:
A lightweight, framework-agnostic scroll restoration solution for React applications.
306 lines (305 loc) • 10.2 kB
JavaScript
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