import { useEffect, useEffectEvent, useRef, useState } from "react"; import { replaceView } from "./history"; import { urlWithParams } from "./urlState"; // Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new // entry) whenever they change. Each provider owns its own key(s) and passes the delta it wants; a null // value clears that key, and urlWithParams preserves every other param (so providers don't clobber each // other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and // PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place. // // Also whenever Back/Forward makes another entry current: the browser restores that entry's address, // which says what the view was when it was left — a change made since on an entry above it (a dialog's // or a drawer's, a sub-view's) is not in it, and a reload would bring the old view back over the one // on screen. (One landed on by an overlay's step back that another step will leave is written too, // and harmlessly: the next landing writes the entry that stays.) // Whether a click or a key press has reached the page since it loaded. A browser without the API // counts as yes: every change is then another view, as before it existed. const userHasActed = (): boolean => (navigator as { userActivation?: { hasBeenActive: boolean } }).userActivation?.hasBeenActive ?? true; export function useUrlMirror(updates: Record): void { // The caller typically passes an inline object literal (new identity every render); key on its // serialized value so the effect only fires when a param actually changes, not every render. const dep = JSON.stringify(updates); // The object itself is read through an effect event, so the effect's reactive inputs are the // serialized value and the landing count — which is exactly what the dep list says: an effect event // for the identity that churns, primitives for the triggers. // A change of what this mirror writes, after its first write, is another view on the same entry // (replaceView: it drops the marks a reload under an overlay left). Its first write on a mount and // its write on a landing name the view the entry already shows, so the marks stay: an overlay that // reopens after a reload still finds its entry. So does a change before the user has done anything // since the load: that is the page settling (the rail stepping off a playlist deleted since), and // the address under the entry names the same screen, settled again on the way back. const written = useRef(null); const mirror = useEffectEvent(() => { const href = urlWithParams(updates); const changed = written.current != null && written.current !== dep && userHasActed(); written.current = dep; // Every mirror runs on every landing: one with nothing to change writes nothing (a browser // throttles a page that rewrites its address too often). if (href === urlWithParams({})) return; if (changed) replaceView(window.history.state ?? {}, href); else window.history.replaceState(window.history.state, "", href); }); // Counted in state, on a task after the popstate dispatch: every listener of it has run by then — // a store that reads its keys back from the landed address among them (Activity) — and so has the // render each one caused (React renders a popstate's updates at once, between listeners). A write // inside the dispatch put the store's old value over the address a later listener was still to // read. const [landings, setLandings] = useState(0); useEffect(() => { let timer = 0; const onPop = () => { window.clearTimeout(timer); timer = window.setTimeout(() => setLandings((n) => n + 1), 0); }; window.addEventListener("popstate", onPop); return () => { window.removeEventListener("popstate", onPop); window.clearTimeout(timer); }; }, []); useEffect(() => { mirror(); }, [dep, landings]); }