createRouter

tcmts.react.createRouter · #jslijxb7 · term · runs in the browser

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,
  };
}

uses

used by