feat(react): add island mount strategies and navigation-safe unmount

Mounts markers with client:only/load/visible/idle, and disposes roots on
route change so React roots, detached DOM, and store subscriptions do
not leak across client-side navigation.

Bundle load failures and malformed props JSON degrade to a warning and
leave the server markup intact rather than taking down the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:20:05 +05:30
co-authored by Claude Opus 5
parent df2ee036eb
commit d269772a79
3 changed files with 230 additions and 0 deletions
+4
View File
@@ -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";
+102
View File
@@ -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<any> }>;
development?: boolean;
}
const roots = new Map<Element, Root>();
export function islandRootCount(): number {
return roots.size;
}
function readProps(element: Element): Record<string, unknown> {
const raw = element.getAttribute("data-wrn-island-props");
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch (error) {
console.error("[wrnexus] island props were not valid JSON", error);
return {};
}
}
function whenReady(element: Element, strategy: string): Promise<void> {
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<void> {
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<any>;
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<void> {
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);
}
}