diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4d7602e5..868ee7b1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,3 +2,7 @@ export { createSelectorCache, createSnapshotCache } from "./snapshot-cache.ts"; export type { SnapshotCache, SnapshotSource } from "./snapshot-cache.ts"; export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts"; export type { BoundStore, IslandStore, StoreResolver } from "./store-bridge.ts"; +export { IslandErrorBoundary } from "./error-boundary.tsx"; +export type { IslandErrorBoundaryProps } from "./error-boundary.tsx"; +export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts"; +export type { MountOptions } from "./island-runtime.ts"; diff --git a/packages/react/src/island-runtime.ts b/packages/react/src/island-runtime.ts new file mode 100644 index 00000000..f9bb96c0 --- /dev/null +++ b/packages/react/src/island-runtime.ts @@ -0,0 +1,102 @@ +import { createElement, type ComponentType } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { IslandErrorBoundary } from "./error-boundary.tsx"; + +export interface MountOptions { + loader: (name: string) => Promise<{ default: ComponentType }>; + development?: boolean; +} + +const roots = new Map(); + +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 whenReady(element: Element, strategy: string): Promise { + if (strategy === "visible" && typeof IntersectionObserver !== "undefined") { + return new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(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)) 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)) return; + + let Component: ComponentType; + try { + Component = (await options.loader(name)).default; + } catch (error) { + console.error(`[wrnexus] failed to load island bundle for '${name}'`, error); + return; + } + + if (roots.has(element)) return; + + const root = 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); + } +} diff --git a/packages/react/test/island-runtime.test.ts b/packages/react/test/island-runtime.test.ts new file mode 100644 index 00000000..800c83a1 --- /dev/null +++ b/packages/react/test/island-runtime.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { islandRootCount, mountIslands, unmountIslands } from "../src/island-runtime.ts"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function domWith(html: string) { + const window = new Window(); + window.document.body.innerHTML = html; + (globalThis as any).window = window; + (globalThis as any).document = window.document; + return window; +} + +const loader = async () => ({ + default: (props: { title?: string }) => createElement("span", null, props.title ?? "none"), +}); + +afterEach(() => { + const doc = (globalThis as any).document; + if (!doc) return; + act(() => { + unmountIslands(doc); + }); +}); + +function marker(props = "{}", strategy = "only") { + return ( + `
` + ); +} + +test("mounts an island and passes deserialized props", async () => { + const window = domWith(marker('{"title":"Revenue"}')); + + await act(async () => { + await mountIslands(window.document.body, { loader }); + }); + + expect(window.document.body.textContent).toContain("Revenue"); + expect(islandRootCount()).toBe(1); +}); + +test("unmounts roots and leaves no leaked roots behind", async () => { + const window = domWith(marker('{"title":"A"}')); + + await act(async () => { + await mountIslands(window.document.body, { loader }); + }); + expect(islandRootCount()).toBe(1); + + act(() => { + unmountIslands(window.document.body); + }); + expect(islandRootCount()).toBe(0); +}); + +test("repeated mount/unmount cycles do not accumulate roots", async () => { + const window = domWith(marker()); + + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await mountIslands(window.document.body, { loader }); + }); + act(() => { + unmountIslands(window.document.body); + }); + } + + expect(islandRootCount()).toBe(0); +}); + +test("does nothing when no island markers are present", async () => { + const window = domWith(`

plain server html

`); + await act(async () => { + await mountIslands(window.document.body, { loader }); + }); + expect(islandRootCount()).toBe(0); +}); + +test("mounting twice does not create a second root for the same element", async () => { + const window = domWith(marker()); + + await act(async () => { + await mountIslands(window.document.body, { loader }); + await mountIslands(window.document.body, { loader }); + }); + + expect(islandRootCount()).toBe(1); +}); + +test("a failing bundle load leaves the placeholder and mounts no root", async () => { + const window = domWith(marker()); + const original = console.error; + console.error = () => {}; + + await act(async () => { + await mountIslands(window.document.body, { + loader: async () => { + throw new Error("network down"); + }, + }); + }); + console.error = original; + + expect(islandRootCount()).toBe(0); + expect(window.document.querySelector("[data-wrn-island]")).not.toBeNull(); +}); + +test("malformed props JSON falls back to empty props instead of throwing", async () => { + const window = domWith(marker("not-json")); + const original = console.error; + console.error = () => {}; + + await act(async () => { + await mountIslands(window.document.body, { loader }); + }); + console.error = original; + + expect(window.document.body.textContent).toContain("none"); + expect(islandRootCount()).toBe(1); +});