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(event ? event.state : history.state);
34 const { scrollWidth, scrollHeight } = scrollTarget;
35 const scrollTo = scrollPositions[id];
36 if (!scrollTo) {
37 // noop
38 } else if (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1]) {
39 scrollTarget.scrollTo(scrollTo[0], scrollTo[1]);
40 } else {
41 if (unsubscribe) unsubscribe();
42 unsubscribe = observeScrollArea(
43 ref === 'window' ? document.body : ref.current!,
44 (scrollWidth: number, scrollHeight: number) => {
45 // check whether the scroll position has already moved too far
46 const halfViewportX = window.innerWidth / 2;
47 const halfViewportY = window.innerHeight / 2;
48 const newScrollTo = scrollPositions[id];
49 const hasMoved =
50 Math.abs(scrollTo[0] - newScrollTo[0]) > halfViewportX ||
51 Math.abs(scrollTo[1] - newScrollTo[1]) > halfViewportY;
52 // then we restore the position as it's now possible
53 if (
54 hasMoved ||
55 (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1])
56 ) {
57 if (!hasMoved) scrollTarget.scrollTo(scrollTo[0], scrollTo[1]);
58 if (unsubscribe) unsubscribe();
59 }
60 }
61 );
62 }
63 }
64
65 function onScroll() {
66 const id = addonId + getIdForState(history.state);
67 const scrollY =
68 ref === 'window' ? window.scrollY : ref.current!.scrollTop;
69 const scrollX =
70 ref === 'window' ? window.scrollX : ref.current!.scrollLeft;
71 scrollPositions[id] = [scrollX, scrollY];
72 if (unsubscribe) {
73 unsubscribe();
74 unsubscribe = undefined;
75 }
76 }
77
78 restoreScroll();
79
80 const eventOpts = { passive: true } as EventListenerOptions;
81 eventTarget.addEventListener('scroll', onScroll, eventOpts);
82 window.addEventListener('popstate', restoreScroll);
83
84 return () => {
85 eventTarget.removeEventListener('scroll', onScroll, eventOpts);
86 window.removeEventListener('popstate', restoreScroll);
87 if (unsubscribe) unsubscribe();
88 };
89 }, [ref]);
90}