Files
siftlode/frontend/src/lib/useUrlMirror.ts
T
peter 5686759bca fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)
- useUrlMirror: a change before the user has done anything since the load is the page settling (the
  rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload
  mark stays, and the player that reopens takes its entry over — one Back closes it, as before.
- useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the
  address names under that module's key (the card's push kept the season's `v` beside its `m`).
- Playlists: the rail's default selection and the page's "the rail moved there" read one helper.
- moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census
  walks type arguments of any depth and refuses an aliased import.
- A stale comment on the nav press over a live search.
2026-10-28 01:39:26 +01:00

68 lines
4.0 KiB
TypeScript

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<string, string | null>): 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<string | null>(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]);
}