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