Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { useLayoutEffect } from './utils/react';
2import { Ref } from './types';
3
4const mutationObservers: Map<HTMLElement, MutationObserver> = new Map();
5const resizeListeners: Map<HTMLElement, Array<() => void>> = new Map();
6
7const resizeObserver = new ResizeObserver(entries => {
8 const parents = new Set<Element>();
9 for (let i = 0; i < entries.length; i++) {
10 const parent = entries[i].target.parentElement;
11 if (parent && !parents.has(parent)) {
12 parents.add(parent);
13 const listeners = resizeListeners.get(parent) || [];
14 for (let i = 0; i < listeners.length; i++) listeners[i]();
15 }
16 }
17});
18
19function observeScrollHeight(
20 element: HTMLElement,
21 onScrollHeightChange: (scrollHeight: number) => void
22): () => void {
23 const listeners = resizeListeners.get(element) || [];
24 const isFirstListener = !listeners.length;
25 resizeListeners.set(element, listeners);
26
27 let previousScrollHeight: null | number = null;
28 let hasUnmounted = false;
29 const onResize = () => {
30 const scrollHeight = element.scrollHeight || 0;
31 if (!hasUnmounted && scrollHeight !== previousScrollHeight) {
32 onScrollHeightChange(element.scrollHeight);
33 previousScrollHeight = scrollHeight;
34 }
35 };
36
37 listeners.push(onResize);
38
39 if (isFirstListener) {
40 const mutationObserver = new MutationObserver(entries => {
41 for (let i = 0; i < entries.length; i++) {
42 const entry = entries[i];
43 for (let j = 0; j < entry.addedNodes.length; j++) {
44 const node = entry.addedNodes[j];
45 if (node.nodeType === Node.ELEMENT_NODE) {
46 resizeObserver.observe(node as Element);
47 }
48 }
49
50 for (let j = 0; j < entry.removedNodes.length; j++) {
51 const node = entry.removedNodes[j];
52 if (node.nodeType === Node.ELEMENT_NODE) {
53 resizeObserver.unobserve(node as Element);
54 }
55 }
56 }
57 });
58
59 const childNodes = element.childNodes;
60 for (let i = 0; i < childNodes.length; i++)
61 if (childNodes[i].nodeType === Node.ELEMENT_NODE)
62 resizeObserver.observe(childNodes[i] as Element);
63
64 mutationObserver.observe(element, { childList: true });
65 mutationObservers.set(element, mutationObserver);
66 }
67
68 requestAnimationFrame(onResize);
69
70 return () => {
71 const listeners = resizeListeners.get(element) || [];
72 listeners.splice(listeners.indexOf(onResize), 1);
73 hasUnmounted = true;
74
75 if (!listeners.length) {
76 const mutationObserver = mutationObservers.get(element);
77 if (mutationObserver) mutationObserver.disconnect();
78
79 const childNodes = element.childNodes;
80 for (let i = 0; i < childNodes.length; i++)
81 if (childNodes[i].nodeType === Node.ELEMENT_NODE)
82 resizeObserver.unobserve(childNodes[i] as Element);
83
84 resizeListeners.delete(element);
85 mutationObservers.delete(element);
86 }
87 };
88}
89
90const getIdForState = (() => {
91 const defaultState = {};
92 const stateToId = new WeakMap<{}, string>();
93
94 let uniqueID = 1;
95
96 return (state?: {} | null): string => {
97 if (!state) state = defaultState;
98 let id = stateToId.get(state);
99 if (!id) stateToId.set(state, (id = (uniqueID++).toString(36)));
100 return `${id}${document.location}`;
101 };
102})();
103
104const scrollPositions: Record<string, number> = {};
105
106export function useScrollRestoration<T extends HTMLElement>(
107 ref: 'window' | Ref<T>
108) {
109 useLayoutEffect(() => {
110 let unsubscribe: void | (() => void);
111 if (ref !== 'window' && !ref.current) return;
112
113 const addonId = ref === 'window' ? 'window' : ref.current!.id || '';
114 const eventTarget = ref === 'window' ? window : ref.current!;
115 const scrollTarget = ref === 'window' ? document.body : ref.current!;
116
117 function restoreScroll(event?: PopStateEvent) {
118 const id = `${addonId}${getIdForState(
119 event ? event.state : history.state
120 )}:${window.location}`;
121 const scrollHeight =
122 ref === 'window'
123 ? document.body.scrollHeight
124 : ref.current!.scrollHeight;
125 const scrollY = scrollPositions[id];
126 if (!scrollY) {
127 // noop
128 } else if (scrollHeight >= scrollY) {
129 scrollTarget.scrollTo(0, scrollY);
130 } else {
131 if (unsubscribe) unsubscribe();
132 unsubscribe = observeScrollHeight(
133 ref === 'window' ? document.body : ref.current!,
134 (scrollHeight: number) => {
135 // the scroll position shouldn't have changed by more than half the screen height
136 const hasMoved =
137 Math.abs(scrollY - scrollPositions[id]) > window.innerHeight / 2;
138 // then we restore the position as it's now possible
139 if (!hasMoved && scrollHeight >= scrollY)
140 scrollTarget.scrollTo(0, scrollY);
141 if (unsubscribe) unsubscribe();
142 }
143 );
144 }
145 }
146
147 function onScroll() {
148 const id = `${addonId}${getIdForState(history.state)}:${window.location}`;
149 const scrollY =
150 ref === 'window' ? window.scrollY : ref.current!.scrollTop;
151 scrollPositions[id] = scrollY || 0;
152 if (unsubscribe) {
153 unsubscribe();
154 unsubscribe = undefined;
155 }
156 }
157
158 restoreScroll();
159
160 const eventOpts = { passive: true } as EventListenerOptions;
161 eventTarget.addEventListener('scroll', onScroll, eventOpts);
162 window.addEventListener('popstate', restoreScroll);
163
164 return () => {
165 eventTarget.removeEventListener('scroll', onScroll, eventOpts);
166 window.removeEventListener('popstate', restoreScroll);
167 if (unsubscribe) unsubscribe();
168 };
169 }, [ref]);
170}