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}