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}