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