Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { useLayoutEffect } from './utils/react';
2import { observeScrollArea } from './utils/observeScrollArea';
3import { Ref } from './types';
4
5const getIdForState = (() => {
6 const defaultState = {};
7 const stateToId = new WeakMap<{}, string>();
8
9 let uniqueID = 1;
10
11 return (state?: {} | null): string => {
12 if (!state) state = defaultState;
13 let id = stateToId.get(state);
14 if (!id) stateToId.set(state, (id = (uniqueID++).toString(36)));
15 return `${id}${document.location}`;
16 };
17})();
18
19const scrollPositions: Record<string, [number, number]> = {};
20
21export function useScrollRestoration<T extends HTMLElement>(
22 ref: 'window' | Ref<T>
23) {
24 useLayoutEffect(() => {
25 let unsubscribe: void | (() => void);
26 if (ref !== 'window' && !ref.current) return;
27
28 const addonId = ref === 'window' ? 'window' : ref.current!.id || '';
29 const eventTarget = ref === 'window' ? window : ref.current!;
30 const scrollTarget = ref === 'window' ? document.body : ref.current!;
31
32 function restoreScroll(event?: PopStateEvent) {
33 const id = `${addonId}${getIdForState(
34 event ? event.state : history.state
35 )}:${window.location}`;
36 const { scrollWidth, scrollHeight } = scrollTarget;
37 const scrollTo = scrollPositions[id];
38 if (!scrollTo) {
39 // noop
40 } else if (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1]) {
41 scrollTarget.scrollTo(scrollTo[0], scrollTo[1]);
42 } else {
43 if (unsubscribe) unsubscribe();
44 unsubscribe = observeScrollArea(
45 ref === 'window' ? document.body : ref.current!,
46 (scrollWidth: number, scrollHeight: number) => {
47 // check whether the scroll position has already moved too far
48 const halfViewportX = window.innerWidth / 2;
49 const halfViewportY = window.innerHeight / 2;
50 const newScrollTo = scrollPositions[id];
51 const hasMoved =
52 Math.abs(scrollTo[0] - newScrollTo[0]) > halfViewportX ||
53 Math.abs(scrollTo[1] - newScrollTo[1]) > halfViewportY;
54 // then we restore the position as it's now possible
55 if (
56 hasMoved ||
57 (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1])
58 ) {
59 if (!hasMoved) scrollTarget.scrollTo(scrollTo[0], scrollTo[1]);
60 if (unsubscribe) unsubscribe();
61 }
62 }
63 );
64 }
65 }
66
67 function onScroll() {
68 const id = `${addonId}${getIdForState(history.state)}:${window.location}`;
69 const scrollY =
70 ref === 'window' ? window.scrollY : ref.current!.scrollTop;
71 const scrollX =
72 ref === 'window' ? window.scrollX : ref.current!.scrollLeft;
73 scrollPositions[id] = [scrollX, scrollY];
74 if (unsubscribe) {
75 unsubscribe();
76 unsubscribe = undefined;
77 }
78 }
79
80 restoreScroll();
81
82 const eventOpts = { passive: true } as EventListenerOptions;
83 eventTarget.addEventListener('scroll', onScroll, eventOpts);
84 window.addEventListener('popstate', restoreScroll);
85
86 return () => {
87 eventTarget.removeEventListener('scroll', onScroll, eventOpts);
88 window.removeEventListener('popstate', restoreScroll);
89 if (unsubscribe) unsubscribe();
90 };
91 }, [ref]);
92}