import { createElement, type ComponentType } from "react"; import { createRoot, type Root } from "react-dom/client"; import { IslandErrorBoundary } from "./error-boundary.tsx"; export interface MountOptions { /** * Resolve an island module by name. * * `generation` counts remounts. A rebuilt island keeps its URL and the * browser caches a module by URL, so a dev loader must fold this into the * request or the page keeps running the code it first imported. */ loader: (name: string, generation: number) => Promise<{ default: ComponentType }>; development?: boolean; /** * Re-render islands that are already mounted instead of skipping them. * * Used by HMR: the container element usually survives the morph, and React * refuses a second `createRoot` on the same container, so the existing root * has to be re-rendered rather than replaced. */ remount?: boolean; } const roots = new Map(); let generation = 0; export function islandRootCount(): number { return roots.size; } function readProps(element: Element): Record { const raw = element.getAttribute("data-wrn-island-props"); if (!raw) return {}; try { return JSON.parse(raw) as Record; } catch (error) { console.error("[wrnexus] island props were not valid JSON", error); return {}; } } function rectOf(element: Element): DOMRect | null { const measure = (element as HTMLElement).getBoundingClientRect; return typeof measure === "function" ? (element as HTMLElement).getBoundingClientRect() : null; } function inViewport(element: Element): boolean { const rect = rectOf(element); if (!rect) return false; const height = window.innerHeight || document.documentElement?.clientHeight || 0; const width = window.innerWidth || document.documentElement?.clientWidth || 0; return rect.top <= height && rect.bottom >= 0 && rect.left <= width && rect.right >= 0; } /** * Resolve once the island's placeholder has come into view. * * An island renders nothing until it mounts, so its placeholder is usually * zero-height -- and IntersectionObserver does not treat a zero-area target * consistently. When it declines to report one, the island never mounts at * all, which is silent: the markup and every asset are present and correct. * Those are driven from the element's own rect instead; a placeholder with * real size (an SSR fallback, or a reserved min-height) still uses the * observer, which is cheaper and needs no scroll listener. */ function whenVisible(element: Element): Promise { if (typeof window === "undefined") return Promise.resolve(); if (inViewport(element)) return Promise.resolve(); const rect = rectOf(element); const hasArea = !!rect && rect.width > 0 && rect.height > 0; if (hasArea && typeof IntersectionObserver !== "undefined") { return new Promise((resolve) => { const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { observer.disconnect(); resolve(); } }); observer.observe(element); }); } return new Promise((resolve) => { const check = () => { if (!inViewport(element)) return; cleanup(); resolve(); }; const cleanup = () => { window.removeEventListener("scroll", check, true); window.removeEventListener("resize", check); }; // Capture phase so a scrolling container, not just the page, wakes it. window.addEventListener("scroll", check, true); window.addEventListener("resize", check); }); } function whenReady(element: Element, strategy: string): Promise { if (strategy === "visible") return whenVisible(element); if (strategy === "idle" && typeof requestIdleCallback !== "undefined") { return new Promise((resolve) => requestIdleCallback(() => resolve())); } return Promise.resolve(); } async function mountOne(element: Element, options: MountOptions): Promise { if (roots.has(element) && !options.remount) return; const name = element.getAttribute("data-wrn-island"); if (!name) return; const strategy = element.getAttribute("data-wrn-island-strategy") ?? "only"; await whenReady(element, strategy); // Re-check: an await point means a concurrent mount may have claimed this // element while the strategy was resolving. if (roots.has(element) && !options.remount) return; let Component: ComponentType; try { Component = (await options.loader(name, generation)).default; } catch (error) { console.error(`[wrnexus] failed to load island bundle for '${name}'`, error); return; } if (roots.has(element) && !options.remount) return; // Reuse an existing root: React rejects a second createRoot on the same // container, and HMR keeps the container across a morph. const root = roots.get(element) ?? createRoot(element); roots.set(element, root); root.render( createElement( IslandErrorBoundary, { name, development: options.development ?? false }, createElement(Component, readProps(element)), ), ); } /** Mounts every island marker under `root`. No-op when the page has none. */ export async function mountIslands(root: ParentNode, options: MountOptions): Promise { const markers = Array.from(root.querySelectorAll("[data-wrn-island]")); if (markers.length === 0) return; await Promise.all(markers.map((element) => mountOne(element, options))); } /** * Disposes island roots under `root`. Must run on client-side navigation or * React roots, detached DOM, and store subscriptions leak on every route change. */ export function unmountIslands(root: ParentNode): void { for (const [element, reactRoot] of [...roots]) { if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) { continue; } try { reactRoot.unmount(); } catch (error) { console.error("[wrnexus] island failed to unmount cleanly", error); } roots.delete(element); } } /** * Dev-only: re-render islands after a source change. * * Island state resets by design — Fast Refresh needs a Babel/SWC transform plus * a runtime and is out of scope. */ export async function remountIslands(root: ParentNode, options: MountOptions): Promise { // A remount only happens after a rebuild, so the modules on the other side // of the loader have changed. generation++; // Every mounted container is swapped for a bare clone before remounting. // // Re-rendering the existing root is not enough: HMR wipes the container's // children externally, and React — whose virtual tree is unchanged — treats // the re-render as a no-op and leaves the island blank. Unmounting instead // throws asynchronously, because the DOM it wants to remove is already gone. // A fresh container sidesteps both, and React accepts createRoot on a node it // has never seen. for (const [element] of [...roots]) { if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) { continue; } roots.delete(element); if (element.isConnected) element.replaceWith(element.cloneNode(false)); } discardDetachedRoots(); await mountIslands(root, options); } /** * Forgets roots whose container left the document. * * HMR morphs server markup over the mounted island, so React's DOM is already * gone by the time we get here; calling unmount then throws asynchronously with * "The node to be removed is not a child of this node". Navigation still uses * `unmountIslands`, where the DOM is intact and cleanup must actually run. */ export function discardDetachedRoots(): void { for (const element of [...roots.keys()]) { if (!element.isConnected) roots.delete(element); } }