Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { useLayoutEffect } from './utils/react';
2import { observeScrollHeight } from './utils/observeScrollHeight';
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> = {};
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 scrollHeight =
37 ref === 'window'
38 ? document.body.scrollHeight
39 : ref.current!.scrollHeight;
40 const scrollY = scrollPositions[id];
41 if (!scrollY) {
42 // noop
43 } else if (scrollHeight >= scrollY) {
44 scrollTarget.scrollTo(0, scrollY);
45 } else {
46 if (unsubscribe) unsubscribe();
47 unsubscribe = observeScrollHeight(
48 ref === 'window' ? document.body : ref.current!,
49 (scrollHeight: number) => {
50 // the scroll position shouldn't have changed by more than half the screen height
51 const hasMoved =
52 Math.abs(scrollY - scrollPositions[id]) > window.innerHeight / 2;
53 // then we restore the position as it's now possible
54 if (!hasMoved && scrollHeight >= scrollY)
55 scrollTarget.scrollTo(0, scrollY);
56 if (unsubscribe) unsubscribe();
57 }
58 );
59 }
60 }
61
62 function onScroll() {
63 const id = `${addonId}${getIdForState(history.state)}:${window.location}`;
64 const scrollY =
65 ref === 'window' ? window.scrollY : ref.current!.scrollTop;
66 scrollPositions[id] = scrollY || 0;
67 if (unsubscribe) {
68 unsubscribe();
69 unsubscribe = undefined;
70 }
71 }
72
73 restoreScroll();
74
75 const eventOpts = { passive: true } as EventListenerOptions;
76 eventTarget.addEventListener('scroll', onScroll, eventOpts);
77 window.addEventListener('popstate', restoreScroll);
78
79 return () => {
80 eventTarget.removeEventListener('scroll', onScroll, eventOpts);
81 window.removeEventListener('popstate', restoreScroll);
82 if (unsubscribe) unsubscribe();
83 };
84 }, [ref]);
85}