createRouter
uses IntersectionObserver
What a page's routes, links and data keep while it's open, over the page's globals (globalThis in the browser;
the server's first paint lacks some of them): the location and its watchers, the data read, the pages kept. One of
them, router, is what the library's hooks and <Pages> use.
State is React state, never useSyncExternalStore: its updates are always blocking, so a navigation through it
would show every <Suspense> fallback it reaches and couldn't animate with <ViewTransition>. A navigation is a
transition instead: what's on screen stays until the next page is ready.
import { match } from "#codebase/tcmts/react"; import { Activity, addTransitionType, createElement, startTransition, useEffect, useLayoutEffect, useRef, useState, } from "react"; import type { ReactNode } from "react"; export function createRouter(page: typeof globalThis) { // the location: re-rendered on back and forward, and on navigate, in a transition typed `navigate` and // `navigate-forward` or `navigate-back` (for <ViewTransition>); `useNavigating` is true until it's on screen const watchers = new Set<() => void>(); const pending = new Set<(p: boolean) => void>(); const changed = (direction: "forward" | "back") => { for (const p of pending) p(true); startTransition(() => { addTransitionType("navigate"); addTransitionType(`navigate-${direction}`); for (const w of watchers) w(); for (const p of pending) p(false); }); }; // how far into the tab's history the page is, kept in its history entries: going to a lower one is back const indexOf = (state: unknown): number => { const n = (state as { tcm?: unknown } | null)?.tcm; return typeof n === "number" ? n : 0; }; let index: number | undefined; const at = () => (index ??= indexOf(page.history.state)); let listening = false; const watch = (w: () => void) => { if (!listening) { page.addEventListener("popstate", (e: PopStateEvent) => { const to = indexOf(e.state); const back = to < at(); index = to; // (just after the event: React renders a transition started in a popstate event at once, fallbacks and // all, and without view transitions; this one keeps the page until the next is ready, as navigate does) setTimeout(() => changed(back ? "back" : "forward")); }); listening = true; } watchers.add(w); return () => void watchers.delete(w); }; const current = () => `${page.location.pathname}${page.location.search}${page.location.hash}`; // the path the app is served under (`tcm start --base`), as the page says: the app's paths are within it const base = () => (page as { __TCM_BASE__?: string }).__TCM_BASE__ ?? ""; const within = (path: string): string | undefined => { const b = base(); return !b ? path : path === b ? "/" : path.startsWith(`${b}/`) ? path.slice(b.length) : undefined; }; function href(path: string): string { // (one already under it, as `location.pathname` is, stays as it is) const b = base(); if ( !path.startsWith("/") || path.startsWith("//") || (b && (path === b || path.startsWith(`${b}/`) || path.startsWith(`${b}?`))) ) return path; return `${b}${path}`; } type Where = { path: string; query: URLSearchParams; hash: string; href: string }; let last: Where | undefined; const whereOf = (at: string): Where => { if (last?.href === at) return last; const url = new URL(at, "http://app"); return (last = { path: within(url.pathname) ?? url.pathname, query: url.searchParams, hash: url.hash, href: at, }); }; // the scroll a navigation asks for, done once its page is on screen let scrollFor: { href: string; hash: string } | undefined; const scroll = (hash: string) => { const target = hash && page.document.getElementById(decodeURIComponent(hash.slice(1))); if (target) target.scrollIntoView(); else page.scrollTo(0, 0); }; function useLocation(): Where { const [here, setHere] = useState(current); useEffect(() => { const w = () => setHere(current()); // (it may have changed since this rendered) w(); return watch(w); }, []); useLayoutEffect(() => { if (scrollFor?.href !== here) return; const { hash } = scrollFor; scrollFor = undefined; scroll(hash); }, [here]); return whereOf(here); } function useNavigating(): boolean { const [navigating, setNavigating] = useState(false); useEffect(() => { pending.add(setNavigating); return () => void pending.delete(setNavigating); }, []); return navigating; } function navigate(to: string, options: { replace?: boolean; scroll?: boolean } = {}): void { const url = new URL(href(to), page.location.href); if (url.origin !== page.location.origin) { page.location.assign(url.href); return; } const before = current(); const next = `${url.pathname}${url.search}${url.hash}`; if (options.replace) page.history.replaceState({ tcm: at() }, "", next); else { index = at() + 1; page.history.pushState({ tcm: index }, "", next); } scrollFor = undefined; if (options.scroll ?? !options.replace) { // (with nothing to render, at once) if (watchers.size && current() !== before) scrollFor = { href: current(), hash: url.hash }; else scroll(url.hash); } changed("forward"); } function useAppLinks(isApp: (path: string) => boolean = () => true): void { const test = useRef(isApp); test.current = isApp; useEffect(() => { type Anchor = { getAttribute(name: string): string | null; hasAttribute(name: string): boolean; }; // the link an event is on, if it's to the app: its URL and its path within the app const appLink = (target: EventTarget | null) => { const t = target as { closest?: (s: string) => Anchor | null } | null; const a = t?.closest?.("a[href]") ?? null; if (!a || a.hasAttribute("download") || (a.getAttribute("target") ?? "_self") !== "_self") return undefined; if ((a.getAttribute("rel") ?? "").split(/\s+/).includes("external")) return undefined; const url = new URL(a.getAttribute("href")!, page.location.href); const path = within(url.pathname); if (url.origin !== page.location.origin || path === undefined || !test.current(path)) return undefined; return { url, path }; }; const onClick = (e: MouseEvent) => { if ( e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) return; const link = appLink(e.target); if (!link) return; const { url, path } = link; // only the fragment changes: the browser scrolls to it if ( url.pathname === page.location.pathname && url.search === page.location.search && url.hash ) return; e.preventDefault(); navigate(`${path}${url.search}${url.hash}`); }; // what the page a link goes to shows, read before it's followed (a static site's trees): when it's pressed, // focused, or pointed at for a moment const g = page as { __tcmPrefetch?: (path: string) => void }; const prefetch = (e: Event) => { const link = appLink(e.target); if (!link || link.url.pathname === page.location.pathname) return; g.__tcmPrefetch?.(link.path); // (and `<Pages>` renders it ahead) intents.forEach((f) => f(`${link.path}${link.url.search}`)); }; let hovered: ReturnType<typeof setTimeout> | undefined; const onOver = (e: Event) => { clearTimeout(hovered); hovered = setTimeout(() => prefetch(e), 60); }; const onOut = () => clearTimeout(hovered); const events: [string, (e: Event) => void][] = [ ["click", onClick as (e: Event) => void], ["pointerdown", prefetch], ["focusin", prefetch], ["pointerover", onOver], ["pointerout", onOut], ]; for (const [name, f] of events) page.document.addEventListener(name, f); // on a static site, where reading a page's trees costs the server nothing: what links in view go to, read when // the page is idle (a phone has nothing to point with), but not on a connection said to be slow or saving data, // and at most two dozen const w = page as { __TCM_STATIC__?: unknown; requestIdleCallback?: (f: () => void) => number; navigator?: { connection?: { saveData?: boolean; effectiveType?: string } }; }; const connection = w.navigator?.connection; const idle = (f: () => void) => w.requestIdleCallback ? w.requestIdleCallback(f) : setTimeout(f, 200); let viewing: IntersectionObserver | undefined; let watching: MutationObserver | undefined; if ( g.__tcmPrefetch && w.__TCM_STATIC__ !== undefined && typeof IntersectionObserver !== "undefined" && !connection?.saveData && !/2g$/.test(connection?.effectiveType ?? "") ) { const read = new Set<string>(); let left = 24; const seen = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; seen.unobserve(e.target); const link = appLink(e.target); if ( !link || left <= 0 || read.has(link.path) || link.url.pathname === page.location.pathname ) continue; read.add(link.path); left--; idle(() => g.__tcmPrefetch!(link.path)); } }); viewing = seen; const scan = () => { for (const a of page.document.querySelectorAll("a[href]")) seen.observe(a); }; scan(); // (and links the page shows later: another page's) let queued = false; watching = new MutationObserver(() => { if (queued) return; queued = true; idle(() => { queued = false; scan(); }); }); watching.observe(page.document.body, { childList: true, subtree: true }); } return () => { clearTimeout(hovered); viewing?.disconnect(); watching?.disconnect(); for (const [name, f] of events) page.document.removeEventListener(name, f); }; }, []); } // data, by key, for the page's life type Shown = { value: unknown; has: boolean; error: Error | undefined; pending: boolean }; type Entry = Shown & { read?: () => Promise<unknown>; running?: Promise<void>; seq: number; listeners: Set<() => void>; snap: Shown; subscribe: (l: () => void) => () => void; }; const EMPTY: Shown = { value: undefined, has: false, error: undefined, pending: false }; const entries = new Map<string, Entry>(); // a page the server rendered shows nothing read until it's hydrated, as its HTML does let hydrating = typeof (page as { __TCM_TREES__?: unknown }).__TCM_TREES__ === "string"; const entryOf = (key: string): Entry => { let e = entries.get(key); if (!e) { const made: Entry = { ...EMPTY, seq: 0, listeners: new Set(), snap: EMPTY, subscribe: (l) => { made.listeners.add(l); return () => void made.listeners.delete(l); }, }; entries.set(key, (e = made)); } return e; }; const publish = (e: Entry) => { e.snap = { value: e.value, has: e.has, error: e.error, pending: e.pending }; for (const l of e.listeners) l(); }; // a read; a later one wins over one still running (what a refresh reads is newer) const load = (e: Entry): Promise<void> => { const n = ++e.seq; const read = e.read!; e.pending = true; publish(e); const done = Promise.resolve() .then(read) .then( (v) => { if (n !== e.seq) return; e.value = v; e.has = true; e.error = undefined; }, (err: unknown) => { if (n === e.seq) e.error = err instanceof Error ? err : new Error(String(err)); }, ) .finally(() => { if (n !== e.seq) return; e.pending = false; e.running = undefined; publish(e); }); e.running = done; return done; }; function useData<T>( key: string | undefined, read: () => Promise<T>, options: { keep?: boolean } = {}, ) { // (not on the server: its first paint shows what's still being read) const e = key === undefined || !page.document ? undefined : entryOf(key); if (e) e.read = read; // what's shown, by key: another key's entry is shown as it is from the first render with it const [held, setHeld] = useState(() => ({ key, snap: e && !hydrating ? e.snap : EMPTY })); const snap = !e ? EMPTY : held.key === key ? held.snap : e.snap; useEffect(() => { hydrating = false; if (!e) return; const update = () => setHeld((h) => (h.key === key && h.snap === e.snap ? h : { key, snap: e.snap })); // (what changed since it rendered: read meanwhile, or read already when it hydrated) update(); const stop = e.subscribe(update); // a refresh between its render and now dropped it (nothing watched it yet): it's shown, so it's kept, and read // again as the refresh would have const dropped = !entries.has(key!); if (dropped) entries.set(key!, e); if (dropped || (!e.has && !e.running)) void load(e); return stop; }, [key]); const shown = useRef<Shown | undefined>(undefined); useEffect(() => { if (snap.has) shown.current = snap; }); const stale = !snap.has && !!options.keep && !!shown.current?.has; return { value: (stale ? shown.current!.value : snap.value) as T | undefined, error: snap.error, loading: key !== undefined && !snap.has && !snap.error, refreshing: snap.has && snap.pending, stale, refresh: (): Promise<void> => (e ? load(e) : Promise.resolve()), }; } function refresh(prefix = ""): Promise<void> { const reads: Promise<void>[] = []; for (const [k, e] of entries) { if (!k.startsWith(prefix)) continue; // what no page shows is read again when one does if (e.listeners.size && e.read) reads.push(load(e)); else entries.delete(k); } return Promise.all(reads).then(() => undefined); } // a PWA's new version, taken over by its service worker: the page's next load is it (`tcm:update`, from the page's // script) function useAppUpdate(): { ready: boolean; reload: () => void } { const [ready, setReady] = useState(false); useEffect(() => { const f = () => setReady(true); if ((page as { __tcmUpdate?: boolean }).__tcmUpdate) f(); page.addEventListener("tcm:update", f); return () => page.removeEventListener("tcm:update", f); }, []); return { ready, reload: () => page.location.reload() }; } // pages a link to was pointed at or pressed: `<Pages>` renders the latest ahead const intents = new Set<(at: string) => void>(); type PageProps = { params: Record<string, string>; query: URLSearchParams }; type PageComponent = (props: PageProps) => ReactNode | Promise<ReactNode>; function Pages({ routes, keep = 3, notFound, }: { routes: Record<string, PageComponent>; keep?: number; notFound?: PageComponent; }): unknown { const here = useLocation(); const at = `${here.path}${here.query.size ? `?${here.query}` : ""}`; // the pages left, the latest first (`keep` of them), and the one pointed at. The page left is kept as the next is // rendered (not after: it would be unmounted in between, its state lost) const [seen, setSeen] = useState<{ at: string; left: string[] }>({ at, left: [] }); let left = seen.left; if (seen.at !== at) { left = [seen.at, ...seen.left.filter((x) => x !== seen.at && x !== at)].slice(0, keep); setSeen({ at, left }); } const [ahead, setAhead] = useState<string | undefined>(undefined); const shown = useRef(at); shown.current = at; useEffect(() => { const f = (to: string) => setAhead(to === shown.current ? undefined : to); intents.add(f); return () => void intents.delete(f); }, []); const pageAt = (to: string, visible: boolean): unknown => { const url = new URL(to, "http://app"); let params: Record<string, string> | undefined; const route = Object.entries(routes).find( ([pattern]) => (params = match(pattern, url.pathname)), ); const component = route?.[1] ?? notFound; if (!component) return null; const content = createElement(component, { params: params ?? {}, query: url.searchParams }); return Activity ? createElement(Activity, { key: to, mode: visible ? "visible" : "hidden", children: content, }) : visible ? content : null; }; const all = [ at, ...left.filter((x) => x !== at), ...(ahead && ahead !== at && !left.includes(ahead) ? [ahead] : []), ]; return all.map((to) => pageAt(to, to === at)); } return { useLocation, useNavigating, navigate, useAppLinks, useData, refresh, useAppUpdate, Pages, }; }