Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add initial useScrollRestoration implementation

+171
+1
src/index.ts
··· 2 2 export * from './useDialogFocus'; 3 3 export * from './useMenuFocus'; 4 4 export * from './useDismissable'; 5 + export * from './useScrollRestoration';
+170
src/useScrollRestoration.ts
··· 1 + import { useLayoutEffect } from './utils/react'; 2 + import { Ref } from './types'; 3 + 4 + const mutationObservers: Map<HTMLElement, MutationObserver> = new Map(); 5 + const resizeListeners: Map<HTMLElement, Array<() => void>> = new Map(); 6 + 7 + const 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 + 19 + function 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 + 90 + const 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 + 104 + const scrollPositions: Record<string, number> = {}; 105 + 106 + export 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 + }