···22export * from './useDialogFocus';
33export * from './useMenuFocus';
44export * from './useDismissable';
55+export * from './useScrollRestoration';
+170
src/useScrollRestoration.ts
···11+import { useLayoutEffect } from './utils/react';
22+import { Ref } from './types';
33+44+const mutationObservers: Map<HTMLElement, MutationObserver> = new Map();
55+const resizeListeners: Map<HTMLElement, Array<() => void>> = new Map();
66+77+const resizeObserver = new ResizeObserver(entries => {
88+ const parents = new Set<Element>();
99+ for (let i = 0; i < entries.length; i++) {
1010+ const parent = entries[i].target.parentElement;
1111+ if (parent && !parents.has(parent)) {
1212+ parents.add(parent);
1313+ const listeners = resizeListeners.get(parent) || [];
1414+ for (let i = 0; i < listeners.length; i++) listeners[i]();
1515+ }
1616+ }
1717+});
1818+1919+function observeScrollHeight(
2020+ element: HTMLElement,
2121+ onScrollHeightChange: (scrollHeight: number) => void
2222+): () => void {
2323+ const listeners = resizeListeners.get(element) || [];
2424+ const isFirstListener = !listeners.length;
2525+ resizeListeners.set(element, listeners);
2626+2727+ let previousScrollHeight: null | number = null;
2828+ let hasUnmounted = false;
2929+ const onResize = () => {
3030+ const scrollHeight = element.scrollHeight || 0;
3131+ if (!hasUnmounted && scrollHeight !== previousScrollHeight) {
3232+ onScrollHeightChange(element.scrollHeight);
3333+ previousScrollHeight = scrollHeight;
3434+ }
3535+ };
3636+3737+ listeners.push(onResize);
3838+3939+ if (isFirstListener) {
4040+ const mutationObserver = new MutationObserver(entries => {
4141+ for (let i = 0; i < entries.length; i++) {
4242+ const entry = entries[i];
4343+ for (let j = 0; j < entry.addedNodes.length; j++) {
4444+ const node = entry.addedNodes[j];
4545+ if (node.nodeType === Node.ELEMENT_NODE) {
4646+ resizeObserver.observe(node as Element);
4747+ }
4848+ }
4949+5050+ for (let j = 0; j < entry.removedNodes.length; j++) {
5151+ const node = entry.removedNodes[j];
5252+ if (node.nodeType === Node.ELEMENT_NODE) {
5353+ resizeObserver.unobserve(node as Element);
5454+ }
5555+ }
5656+ }
5757+ });
5858+5959+ const childNodes = element.childNodes;
6060+ for (let i = 0; i < childNodes.length; i++)
6161+ if (childNodes[i].nodeType === Node.ELEMENT_NODE)
6262+ resizeObserver.observe(childNodes[i] as Element);
6363+6464+ mutationObserver.observe(element, { childList: true });
6565+ mutationObservers.set(element, mutationObserver);
6666+ }
6767+6868+ requestAnimationFrame(onResize);
6969+7070+ return () => {
7171+ const listeners = resizeListeners.get(element) || [];
7272+ listeners.splice(listeners.indexOf(onResize), 1);
7373+ hasUnmounted = true;
7474+7575+ if (!listeners.length) {
7676+ const mutationObserver = mutationObservers.get(element);
7777+ if (mutationObserver) mutationObserver.disconnect();
7878+7979+ const childNodes = element.childNodes;
8080+ for (let i = 0; i < childNodes.length; i++)
8181+ if (childNodes[i].nodeType === Node.ELEMENT_NODE)
8282+ resizeObserver.unobserve(childNodes[i] as Element);
8383+8484+ resizeListeners.delete(element);
8585+ mutationObservers.delete(element);
8686+ }
8787+ };
8888+}
8989+9090+const getIdForState = (() => {
9191+ const defaultState = {};
9292+ const stateToId = new WeakMap<{}, string>();
9393+9494+ let uniqueID = 1;
9595+9696+ return (state?: {} | null): string => {
9797+ if (!state) state = defaultState;
9898+ let id = stateToId.get(state);
9999+ if (!id) stateToId.set(state, (id = (uniqueID++).toString(36)));
100100+ return `${id}${document.location}`;
101101+ };
102102+})();
103103+104104+const scrollPositions: Record<string, number> = {};
105105+106106+export function useScrollRestoration<T extends HTMLElement>(
107107+ ref: 'window' | Ref<T>
108108+) {
109109+ useLayoutEffect(() => {
110110+ let unsubscribe: void | (() => void);
111111+ if (ref !== 'window' && !ref.current) return;
112112+113113+ const addonId = ref === 'window' ? 'window' : ref.current!.id || '';
114114+ const eventTarget = ref === 'window' ? window : ref.current!;
115115+ const scrollTarget = ref === 'window' ? document.body : ref.current!;
116116+117117+ function restoreScroll(event?: PopStateEvent) {
118118+ const id = `${addonId}${getIdForState(
119119+ event ? event.state : history.state
120120+ )}:${window.location}`;
121121+ const scrollHeight =
122122+ ref === 'window'
123123+ ? document.body.scrollHeight
124124+ : ref.current!.scrollHeight;
125125+ const scrollY = scrollPositions[id];
126126+ if (!scrollY) {
127127+ // noop
128128+ } else if (scrollHeight >= scrollY) {
129129+ scrollTarget.scrollTo(0, scrollY);
130130+ } else {
131131+ if (unsubscribe) unsubscribe();
132132+ unsubscribe = observeScrollHeight(
133133+ ref === 'window' ? document.body : ref.current!,
134134+ (scrollHeight: number) => {
135135+ // the scroll position shouldn't have changed by more than half the screen height
136136+ const hasMoved =
137137+ Math.abs(scrollY - scrollPositions[id]) > window.innerHeight / 2;
138138+ // then we restore the position as it's now possible
139139+ if (!hasMoved && scrollHeight >= scrollY)
140140+ scrollTarget.scrollTo(0, scrollY);
141141+ if (unsubscribe) unsubscribe();
142142+ }
143143+ );
144144+ }
145145+ }
146146+147147+ function onScroll() {
148148+ const id = `${addonId}${getIdForState(history.state)}:${window.location}`;
149149+ const scrollY =
150150+ ref === 'window' ? window.scrollY : ref.current!.scrollTop;
151151+ scrollPositions[id] = scrollY || 0;
152152+ if (unsubscribe) {
153153+ unsubscribe();
154154+ unsubscribe = undefined;
155155+ }
156156+ }
157157+158158+ restoreScroll();
159159+160160+ const eventOpts = { passive: true } as EventListenerOptions;
161161+ eventTarget.addEventListener('scroll', onScroll, eventOpts);
162162+ window.addEventListener('popstate', restoreScroll);
163163+164164+ return () => {
165165+ eventTarget.removeEventListener('scroll', onScroll, eventOpts);
166166+ window.removeEventListener('popstate', restoreScroll);
167167+ if (unsubscribe) unsubscribe();
168168+ };
169169+ }, [ref]);
170170+}