UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

490 lines (419 loc) 14.8 kB
import { $atom, $hook, $inject, $state, Alepha, SchemaValidator, type State, type Static, z, } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { BrowserHeadProvider } from "alepha/react/head"; import { LinkProvider } from "alepha/server/links"; import type { RouterPushOptions } from "../services/ReactRouter.ts"; import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts"; import type { PreviousLayerData, ReactRouterState, } from "./ReactPageProvider.ts"; export type { RouterPushOptions } from "../services/ReactRouter.ts"; /** * React browser renderer configuration atom */ export const reactBrowserOptions = $atom({ name: "alepha.react.browser.options", schema: z.object({ scrollRestoration: z.enum(["top", "manual"]), // TODO: must be per page? /** * Intercept clicks on plain `<a href="/...">` anchors and route them * through the SPA router, so authors don't need `<Link>` everywhere * (notably for SSR/Markdown HTML rendered as raw markup). * * Skips: modifier keys, non-primary mouse buttons, `target` other than * `_self`, `download`, `data-no-router`, non-http(s) schemes, hash-only * hrefs, external origins, and clicks already `defaultPrevented`. */ interceptAnchorClicks: z.boolean().default(true), }), default: { scrollRestoration: "top" as const, interceptAnchorClicks: true, }, }); export type ReactBrowserRendererOptions = Static< typeof reactBrowserOptions.schema >; declare module "alepha" { interface State { [reactBrowserOptions.key]: ReactBrowserRendererOptions; } } // --------------------------------------------------------------------------------------------------------------------- export class ReactBrowserProvider { protected readonly log = $logger(); protected readonly client = $inject(LinkProvider); protected readonly alepha = $inject(Alepha); protected readonly router = $inject(ReactBrowserRouterProvider); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly browserHeadProvider = $inject(BrowserHeadProvider); protected readonly validator = $inject(SchemaValidator); protected readonly options = $state(reactBrowserOptions); public get rootId() { return "root"; } protected getRootElement() { const root = this.document.getElementById(this.rootId); if (root) { return root; } const div = this.document.createElement("div"); div.id = this.rootId; this.document.body.prepend(div); return div; } public transitioning?: { to: string; from?: string; }; /** * Monotonic counter used to detect stale (superseded) transitions. * * Each call to `render()` captures `++this.transitionId` and any * subsequent `render()` invalidates older in-flight transitions. * This prevents a slow page from racing past a newer navigation * (e.g. user clicks /pageA which has a 2s loader, then clicks /pageB * — pageB must remain the committed page). */ protected transitionId = 0; public get state(): ReactRouterState { return this.alepha.store.get("alepha.react.router.state")!; } /** * Accessor for Document DOM API. */ public get document() { return window.document; } /** * Accessor for History DOM API. */ public get history() { return window.history; } /** * Accessor for Location DOM API. */ public get location() { return window.location; } public get base() { const base = import.meta.env?.BASE_URL; if (!base || base === "/") { return ""; } return base; } public get url(): string { const url = this.location.pathname + this.location.search + this.location.hash; if (this.base) { return url.replace(this.base, ""); } return url; } public pushState(path: string, replace?: boolean) { const url = this.base + path; if (replace) { this.history.replaceState({}, "", url); } else { this.history.pushState({}, "", url); } } public async invalidate(props?: Record<string, any>) { const previous: PreviousLayerData[] = []; this.log.trace("Invalidating layers"); if (props) { const [key] = Object.keys(props); const value = props[key]; for (const layer of this.state.layers) { if (layer.props?.[key]) { previous.push({ ...layer, props: { ...layer.props, [key]: value, }, }); break; } previous.push(layer); } } await this.render({ previous }); } public async push( url: string, options: RouterPushOptions = {}, ): Promise<void> { this.log.trace(`Going to ${url}`, { url, options, }); const myTransitionId = ++this.transitionId; await this.render({ url, previous: options.force ? [] : this.state.layers, meta: options.meta, transitionId: myTransitionId, }); // A newer navigation has superseded us — bail out without touching // history, otherwise we'd push a duplicate/stale entry. if (myTransitionId !== this.transitionId) { return; } // when redirecting in browser // The hash is part of the identity of the committed route: without it, // pushing "/docs#section" never matches "/docs" and takes the redirect // branch below, which rewrites history without the fragment. const committed = this.state.url.pathname + this.state.url.search + this.state.url.hash; if (committed !== url) { this.pushState(committed); return; } this.pushState(url, options.replace); } protected async render(options: RouterRenderOptions = {}): Promise<void> { const myTransitionId = options.transitionId ?? ++this.transitionId; const previous = options.previous ?? this.state.layers; const url = options.url ?? this.url; const start = this.dateTimeProvider.now(); this.transitioning = { to: url, from: this.state?.url.pathname, }; this.log.debug("Transitioning...", { to: url, }); const isStale = () => this.transitionId !== myTransitionId; const redirect = await this.router.transition( new URL(`http://localhost${url}`), previous, options.meta, isStale, ); // A newer navigation has superseded us between the time we awaited // transition() and now. Drop everything: don't follow redirects, don't // log success, don't clear `transitioning` (the newer render owns it). if (isStale()) { this.log.debug("Transition superseded — discarding stale result", { to: url, }); return; } if (redirect) { this.log.info("Redirecting to", { redirect, }); // if redirect is an absolute URL, use window.location.href (full page reload) if (redirect.startsWith("http")) { window.location.href = redirect; } else { // if redirect is a relative URL, use render() (single page app). // Inherit the current transitionId: a redirect is a continuation of // the same navigation, not a new one. Allocating a fresh id would // mark the caller's `push()` stale, so it would skip `pushState()` // and the URL bar would never sync to the redirect target. return await this.render({ url: redirect, transitionId: myTransitionId, }); } } const ms = this.dateTimeProvider.now().diff(start); this.log.info(`Transition OK [${ms}ms]`, this.transitioning); this.transitioning = undefined; } /** * Get embedded layers from the server. */ protected getHydrationState(): ReactHydrationState | undefined { try { const el = this.document.getElementById("__ssr"); if (el?.textContent) { return JSON.parse(el.textContent) as ReactHydrationState; } } catch (error) { console.error(error); } } /** * Apply the SSR hydration payload (the `#__ssr` script tag) to the atom * store. * * Every key except `alepha.react.router.layers` is treated as an atom * value. A registered atom's value is explicitly schema-validated: an * invalid value is dropped (warn + keep the atom's default) instead of * being trusted, so a tampered payload can't smuggle bad data into a * validated atom. Atoms not registered yet fall through to * `Alepha.set()`, which lets `StateManager.register()` decode them * against the schema the moment they first get used. * * `alepha.react.router.layers` is deliberately skipped by this loop: it * carries render instructions (`part`, `name`, `config`, `props`, * `error`), not atom values. Those are NOT hardened here and are trusted * as-is from the SSR payload — a tampered payload can still influence * rendering through this key. Validating router layers is separate, * future work; this method only guarantees atom values. */ protected applyHydration(hydration: ReactHydrationState): void { for (const [key, value] of Object.entries(hydration)) { if (key === "alepha.react.router.layers") { // Render instructions, not an atom value — see the method doc // above. Intentionally trusted as-is. continue; } const atom = this.alepha.store.getAtom(key); if (atom) { const result = this.validator.safeValidate(atom.schema, value); if (!result.success) { this.log.warn( `Hydrated value for atom "${key}" failed schema validation, keeping default`, ); continue; } this.alepha.store.set(key as keyof State, result.data as any, { skipValidation: true, }); } else { // Not registered yet — register() will decode it when the atom // first gets used. this.alepha.set(key as keyof State, value); } } } // ------------------------------------------------------------------------------------------------------------------- protected readonly onTransitionEnd = $hook({ on: "react:transition:end", handler: () => { if ( this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest() ) { this.log.trace("Restoring scroll position to top"); window.scrollTo(0, 0); } }, }); public readonly ready = $hook({ on: "ready", handler: async () => { const hydration = this.getHydrationState(); const previous = hydration?.["alepha.react.router.layers"] ?? []; if (hydration) { this.applyHydration(hydration); } await this.render({ previous }); const element = this.router.root(this.state); await this.alepha.events.emit("react:browser:render", { element, root: this.getRootElement(), hydration, state: this.state, }); window.addEventListener("popstate", () => { // Skip rendering only if the entire URL (path + search) is // unchanged from current state. Comparing pathname alone misses // back/forward between two URLs that share a path but differ // in query params (e.g. an in-page filter `?dir=5` → root) — // those legitimately need a re-render. If you want to update // query params silently without triggering a render, use // history.replaceState directly; popstate by definition means // the user navigated. if ( this.base + this.state.url.pathname === this.location.pathname && (this.state.url.search ?? "") === (this.location.search ?? "") ) { return; } this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search, }); this.render(); }); this.attachAnchorInterceptor(); }, }); /** * Attach a delegated click listener that routes plain `<a href="/...">` * clicks through the SPA router. Returns a detach function (used in tests). * * Bails out on modifier keys, non-primary mouse buttons, `target`, `download`, * `data-no-router`, hash-only/external/non-http hrefs, and already-prevented * events. Honors the runtime `interceptAnchorClicks` flag. */ protected attachAnchorInterceptor(): () => void { const onClick = (ev: MouseEvent) => { if (!this.options.interceptAnchorClicks) return; if (ev.defaultPrevented) return; if (ev.button !== 0) return; if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; const node = ev.target as Element | null; const a = node?.closest?.("a"); if (!a) return; if (a.hasAttribute("download")) return; if (a.hasAttribute("data-no-router")) return; const target = a.getAttribute("target"); if (target && target !== "_self") return; const href = a.getAttribute("href"); if (!href) return; if (href.startsWith("#")) return; if (/^[a-z][a-z0-9+.-]*:/i.test(href)) { // absolute scheme: only intercept if it points at our own origin let url: URL; try { url = new URL(href); } catch { return; } if (url.origin !== this.location.origin) return; ev.preventDefault(); const path = url.pathname + url.search + url.hash; this.push(this.stripBase(path)).catch((e) => this.log.error(e)); return; } ev.preventDefault(); const url = new URL(href, this.location.href); const path = url.pathname + url.search + url.hash; this.push(this.stripBase(path)).catch((e) => this.log.error(e)); }; this.document.addEventListener("click", onClick); return () => this.document.removeEventListener("click", onClick); } protected stripBase(path: string): string { if (this.base && path.startsWith(this.base)) { return path.slice(this.base.length) || "/"; } return path; } } // --------------------------------------------------------------------------------------------------------------------- export type ReactHydrationState = { "alepha.react.router.layers"?: Array<PreviousLayerData>; } & { [key: string]: any; }; export interface RouterRenderOptions { url?: string; previous?: PreviousLayerData[]; meta?: Record<string, any>; /** * Transition id used to detect supersession by a newer navigation. * When omitted, render() allocates a fresh id internally. */ transitionId?: number; }