/** * Mandu Per-Island Hydration Scheduler * * Phase 18.δ — Formal hydration strategy spec for Islands. * * Strategies (Astro-compatible naming): * - `load` — hydrate immediately (current default; equiv. to Astro `client:load`) * - `idle` — `requestIdleCallback` (Astro `client:idle`) * - `visible` — `IntersectionObserver` rootMargin 200px (Astro `client:visible`) * - `interaction` — hydrate on first click/touchstart/keydown (Astro `client:only` equivalent for events) * - `media()` — hydrate only when `matchMedia(query).matches` (Astro `client:media`) * * This module is the **single source of truth** for hydration scheduling on * the client. It is intentionally side-effect-free on import so that: * 1. Bundlers can tree-shake unused strategies. * 2. Unit tests can import it under happy-dom/JSDOM without triggering * global mutation (no `document.querySelectorAll` on module eval). * * The bundled runtime entry (`client/runtime-entry.ts`) * delegates strategy selection to `scheduleHydration()` here — SSR emits the * `data-hydrate` attribute, the runtime reads it and dispatches. * * Design contract: * - Every strategy invokes its `hydrate` callback at most ONCE. * - Every strategy returns a `dispose()` cleanup that detaches observers * and event listeners (no memory leaks on route change / unmount). * - Unknown / malformed strategies fall back to `load` with a console * warning so broken islands never silently stay un-interactive. */ export type HydrationStrategyName = | "load" | "idle" | "visible" | "interaction" | "media"; export interface ParsedStrategy { name: HydrationStrategyName; /** media query string when `name === "media"`, else `undefined`. */ media?: string; } /** `rootMargin` used by the `visible` IntersectionObserver. */ export const VISIBLE_ROOT_MARGIN = "200px"; /** Events that trigger `interaction` hydration. */ export const INTERACTION_EVENTS = ["click", "touchstart", "keydown"] as const; /** * Parse a `data-hydrate` attribute value into a normalized strategy. * * Accepts: * - `"load"` | `"idle"` | `"visible"` | `"interaction"` * - `"media(min-width: 768px)"` — media query in parentheses * * Returns `{ name: "load" }` with a `console.warn` for unknown/malformed * inputs (fail-open default — never leave an island dead). */ export function parseHydrateStrategy( attr: string | null | undefined, ): ParsedStrategy { if (!attr) return { name: "load" }; const trimmed = attr.trim(); if ( trimmed === "load" || trimmed === "idle" || trimmed === "visible" || trimmed === "interaction" ) { return { name: trimmed }; } // media() — extract inner query const mediaMatch = /^media\(\s*(.+?)\s*\)$/.exec(trimmed); if (mediaMatch && mediaMatch[1]) { return { name: "media", media: mediaMatch[1] }; } // Fallback: legacy `immediate` alias → `load` if (trimmed === "immediate") return { name: "load" }; if (typeof console !== "undefined" && typeof console.warn === "function") { console.warn( `[Mandu] Unknown hydrate strategy "${attr}", falling back to "load".`, ); } return { name: "load" }; } /** * A function returned by each strategy scheduler. Invoke to detach any * observers / listeners BEFORE the actual hydration kicks in. Safe to call * multiple times (idempotent). */ export type Disposer = () => void; function noopDispose(): Disposer { return () => {}; } /** * `load` — hydrate synchronously on next microtask. * * We use a microtask (Promise.resolve().then) rather than calling `hydrate` * directly so that callers can install listeners on `mandu:hydrated` events * after `scheduleHydration` returns. Matches Astro `client:load` semantics. */ function scheduleLoad(hydrate: () => void): Disposer { let cancelled = false; void Promise.resolve().then(() => { if (!cancelled) hydrate(); }); return () => { cancelled = true; }; } /** * `idle` — hydrate when the browser is idle. * Falls back to `setTimeout(200)` when `requestIdleCallback` is unavailable * (Safari < 16.4, some WebViews). */ function scheduleIdle(hydrate: () => void): Disposer { const w = typeof window !== "undefined" ? window : undefined; if (!w) return noopDispose(); if (typeof w.requestIdleCallback === "function") { const handle = w.requestIdleCallback(() => hydrate()); return () => { if (typeof w.cancelIdleCallback === "function") { w.cancelIdleCallback(handle); } }; } const timer = setTimeout(hydrate, 200); return () => clearTimeout(timer); } /** * `visible` — hydrate when the element enters the viewport (+200px margin). * Falls back to immediate hydration when `IntersectionObserver` is missing. */ function scheduleVisible( element: Element, hydrate: () => void, ): Disposer { if (typeof IntersectionObserver === "undefined") { hydrate(); return noopDispose(); } let disposed = false; const observer = new IntersectionObserver( (entries) => { if (disposed) return; for (const entry of entries) { if (entry.isIntersecting) { disposed = true; observer.disconnect(); hydrate(); return; } } }, { rootMargin: VISIBLE_ROOT_MARGIN }, ); // `display:contents` wrappers have zero layout size → observe the first // element child when available so IntersectionObserver sees real geometry. const target = resolveObservationTarget(element); observer.observe(target); return () => { disposed = true; observer.disconnect(); }; } /** * `interaction` — hydrate on the first click / touchstart / keydown within * the island. Matches the task spec (click/touch/keydown only; no * mouseenter or pointerdown to keep scroll & hover passive). */ function scheduleInteraction( element: Element, hydrate: () => void, ): Disposer { let fired = false; const target = resolveObservationTarget(element); const onEvent = () => { if (fired) return; fired = true; dispose(); hydrate(); }; const dispose: Disposer = () => { for (const evt of INTERACTION_EVENTS) { target.removeEventListener(evt, onEvent, true); } }; for (const evt of INTERACTION_EVENTS) { // Capture phase so the island hydrates BEFORE the user's click bubbles // to their still-dehydrated event handler. target.addEventListener(evt, onEvent, { capture: true, once: false }); } return dispose; } /** * `media()` — hydrate only when `matchMedia(query).matches` becomes * true. If already matching on mount, hydrate immediately; otherwise wait * for the first `change` event. */ function scheduleMedia(query: string, hydrate: () => void): Disposer { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { // No matchMedia support → fall back to `load` so the island is at least // interactive. Conservative: users can't debug silent no-ops. hydrate(); return noopDispose(); } const mql = window.matchMedia(query); if (mql.matches) { hydrate(); return noopDispose(); } let disposed = false; const onChange = (e: MediaQueryListEvent) => { if (disposed) return; if (e.matches) { disposed = true; detach(); hydrate(); } }; const detach: Disposer = () => { if (typeof mql.removeEventListener === "function") { mql.removeEventListener("change", onChange); } else if (typeof (mql as unknown as { removeListener?: (l: unknown) => void }).removeListener === "function") { // Safari < 14 legacy API (mql as unknown as { removeListener: (l: (e: MediaQueryListEvent) => void) => void }).removeListener(onChange); } }; if (typeof mql.addEventListener === "function") { mql.addEventListener("change", onChange); } else if (typeof (mql as unknown as { addListener?: (l: unknown) => void }).addListener === "function") { (mql as unknown as { addListener: (l: (e: MediaQueryListEvent) => void) => void }).addListener(onChange); } return () => { disposed = true; detach(); }; } /** * Resolve the DOM node to actually observe / listen on. Island wrappers use * `style="display:contents"` which has zero layout box — IntersectionObserver * refuses to fire for such elements. Promote to the first element child when * available, else fall back to the wrapper itself. */ function resolveObservationTarget(element: Element): Element { if (typeof window === "undefined" || typeof getComputedStyle !== "function") { return element; } try { const cs = getComputedStyle(element); if (cs.display === "contents" && element.firstElementChild) { return element.firstElementChild; } } catch { /* happy-dom may reject partial stylesheet queries — treat as layout element */ } return element; } /** * Public entry — schedule hydration of `element` according to `strategy`, * invoking `hydrate` at most once. Returns a disposer for cleanup. * * Unknown strategies degrade to `load` with a console warning. * * @param element — the island root (typically `[data-mandu-island]`) * @param strategy — either a `ParsedStrategy` from `parseHydrateStrategy` * OR a raw attribute string (e.g. `"media(min-width: 768px)"`) * @param hydrate — callback invoked once when the strategy trigger fires */ export function scheduleHydration( element: Element, strategy: ParsedStrategy | string | null | undefined, hydrate: () => void, ): Disposer { const parsed = typeof strategy === "string" || strategy == null ? parseHydrateStrategy(strategy as string | null | undefined) : strategy; switch (parsed.name) { case "load": return scheduleLoad(hydrate); case "idle": return scheduleIdle(hydrate); case "visible": return scheduleVisible(element, hydrate); case "interaction": return scheduleInteraction(element, hydrate); case "media": return parsed.media ? scheduleMedia(parsed.media, hydrate) : scheduleLoad(hydrate); default: // Exhaustiveness: if a new strategy is added to the union without a // handler, TypeScript flags the `never` check below at compile time. ((_x: never) => _x)(parsed.name); return scheduleLoad(hydrate); } } /** * Internal test helpers — expose resolveObservationTarget so that unit * tests can verify the `display:contents` promotion rule without relying * on public API side-effects. * * @internal */ export const _testOnly_resolveObservationTarget = resolveObservationTarget;