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:
@@ -2,3 +2,7 @@ export { createSelectorCache, createSnapshotCache } from "./snapshot-cache.ts";
|
|||||||
export type { SnapshotCache, SnapshotSource } from "./snapshot-cache.ts";
|
export type { SnapshotCache, SnapshotSource } from "./snapshot-cache.ts";
|
||||||
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
|
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
|
||||||
export type { BoundStore, IslandStore, StoreResolver } 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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
`<div data-wrn-island="Chart" data-wrn-island-strategy="${strategy}"` +
|
||||||
|
` data-wrn-island-props='${props}'></div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<p>plain server html</p>`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user