Files
WRNexusJS/packages/react/src/island-runtime.ts
T
ClintchizandClaude Opus 5 e898929193
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(react): mount visible islands and load rebuilt code after HMR
Two faults found by driving the island demo in a real browser. Both were
silent: the markup, every asset, and all 48 island tests were correct
either way.

An island renders nothing until it mounts, so its placeholder is
zero-height, and IntersectionObserver does not treat a zero-area target
consistently -- client:visible islands mounted on one load and not the
next. Visibility for those is now decided from the element's own rect,
driven by scroll and resize; a placeholder with real size still uses the
observer. The strategy had no test at all, which is why this shipped.

After an island source edit the browser kept running the old code. The
rebuild worked and the file was refetched, but the loader imports a URL
that does not change, and the browser caches modules by URL. Remounts now
carry a generation the dev loader folds into the request.

Verified in the browser: mounts with start={3} as a number, clicks reach
React (3 -> 5), and an edit to Counter.tsx now shows the new text and
stays interactive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:32:05 +05:30

217 lines
7.7 KiB
TypeScript

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<any> }>;
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<Element, Root>();
let generation = 0;
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 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<void> {
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<void> {
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<void> {
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<any>;
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<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);
}
}
/**
* 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<void> {
// 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);
}
}