Formatting only; no content change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
58 KiB
React Islands Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let WRNexus authors use npm React components as opt-in, client-only islands inside .wrn pages, without changing the SSR-first rendering model.
Architecture: A new isolated @wrnexus/react package holds every React-specific line, with react/react-dom as optional peer dependencies. The compiler detects .tsx imports in .wrn frontmatter and emits a data-wrn-island placeholder instead of a server render. A lazily-loaded browser runtime mounts each island with createRoot, and a store bridge built on useSyncExternalStore gives islands two-way access to WRNexus stores.
Tech Stack: TypeScript 6.0.3, Bun (test + build), React 19, happy-dom for DOM tests.
Spec: docs/superpowers/specs/2026-08-18-react-islands-design.md
Global Constraints
- Package manager and test runner is Bun. Tests run via
bun test. reactandreact-domare optional peer dependencies of@wrnexus/react. They must never become dependencies ofcore,csr,store,compiler, ordev-server.- A route with no islands must ship zero framework JavaScript. This is the project's differentiator and is guarded by an integration test.
- A page with multiple islands must ship React exactly once.
- Islands are client-only in v1. No
react-dom/serverimport anywhere. @wrnexus/storesource must remain unmodified. The snapshot cache lives in@wrnexus/react.- Island props must be JSON-serializable; violations are compile-time errors.
- Diagnostic codes, exact strings:
WRN-ISLAND-PROPS,WRN-ISLAND-REACT-MISSING. - Marker attributes, exact names:
data-wrn-island,data-wrn-island-strategy,data-wrn-island-props. - Strategy values, exact strings:
only,load,visible,idle. Default isonly. - Asset routes:
/__wrnexus/islands.jsand/__wrnexus/island/<hash>.js. - Existing test style:
import { expect, test } from "bun:test";— no describe blocks required. - Commit after every task. Never use
--no-verify.
File Structure
New package packages/react/:
| File | Responsibility |
|---|---|
package.json |
Package manifest; optional peer deps |
src/snapshot-cache.ts |
Referentially-stable snapshot + selector caching. No React import. |
src/store-bridge.ts |
useWrnStore hook over useSyncExternalStore |
src/error-boundary.tsx |
Per-island React error boundary |
src/island-runtime.ts |
Mount/unmount, strategies, root registry |
src/runtime-source.ts |
getIslandRuntime() returning browser JS (mirrors @wrnexus/csr) |
src/index.ts |
Public exports |
Modified:
| File | Change |
|---|---|
packages/compiler/src/import-resolver.ts |
Resolve .tsx; tag kind: "island" |
packages/compiler/src/island-codegen.ts (new) |
Marker emission, props serialization, diagnostics |
packages/compiler/src/island-bundle.ts (new) |
Island entry generation + Bun.build with shared React chunk |
packages/dev-server/src/assets.ts |
Serve island routes in dev |
packages/dev-server/src/prod.ts |
Serve island routes in prod |
packages/cli/src/build.ts |
Emit island assets in static build |
Task 1: Snapshot cache
The load-bearing piece. readonlySnapshot in @wrnexus/store returns a fresh Object.freeze(clone(state)) on every call; useSyncExternalStore requires a stable reference or it throws and infinite-loops. This task builds the cache with no React dependency, so it is testable in isolation.
Files:
- Create:
packages/react/package.json - Create:
packages/react/src/snapshot-cache.ts - Test:
packages/react/test/snapshot-cache.test.ts
Interfaces:
-
Consumes: nothing.
-
Produces:
createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>interface SnapshotSource<S> { snapshot(): Readonly<S>; subscribe(listener: () => void): () => void }interface SnapshotCache<S> { getSnapshot(): Readonly<S>; dispose(): void }createSelectorCache<S, R>(getSnapshot: () => Readonly<S>, selector: (state: Readonly<S>) => R): () => R
-
Step 1: Create the package manifest
Create packages/react/package.json:
{
"name": "@wrnexus/react",
"version": "0.8.8",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./runtime": "./src/runtime-source.ts"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependenciesMeta": {
"react": { "optional": true },
"react-dom": { "optional": true }
},
"dependencies": {
"@wrnexus/store": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3"
}
}
- Step 2: Write the failing test
Create packages/react/test/snapshot-cache.test.ts:
import { expect, test } from "bun:test";
import { createSelectorCache, createSnapshotCache } from "../src/snapshot-cache.ts";
function fakeSource(initial: { count: number }) {
let state = { ...initial };
const listeners = new Set<() => void>();
return {
snapshot: () => Object.freeze({ ...state }),
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
mutate(next: { count: number }) {
state = next;
for (const listener of [...listeners]) listener();
},
};
}
test("returns a referentially identical snapshot until a mutation occurs", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
const first = cache.getSnapshot();
const second = cache.getSnapshot();
expect(first).toBe(second);
source.mutate({ count: 1 });
const third = cache.getSnapshot();
expect(third).not.toBe(first);
expect(third.count).toBe(1);
});
test("dispose unsubscribes from the source", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
cache.getSnapshot();
cache.dispose();
source.mutate({ count: 5 });
expect(cache.getSnapshot().count).toBe(0);
});
test("selector cache keeps a stable result when the selected value is unchanged", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
const select = createSelectorCache(cache.getSnapshot, (state) => ({ label: `n=${state.count}` }));
const first = select();
expect(select()).toBe(first);
source.mutate({ count: 0 });
expect(select()).toBe(first);
source.mutate({ count: 2 });
expect(select()).not.toBe(first);
expect(select().label).toBe("n=2");
});
- Step 3: Run test to verify it fails
Run: bun test packages/react/test/snapshot-cache.test.ts
Expected: FAIL — cannot resolve ../src/snapshot-cache.ts
- Step 4: Write minimal implementation
Create packages/react/src/snapshot-cache.ts:
export interface SnapshotSource<S extends object> {
snapshot(): Readonly<S>;
subscribe(listener: () => void): () => void;
}
export interface SnapshotCache<S extends object> {
getSnapshot(): Readonly<S>;
dispose(): void;
}
/**
* Wraps a store instance so repeated `getSnapshot()` calls return the same
* reference until the store notifies a change. `useSyncExternalStore` throws
* and spins if given a fresh object each call, which `@wrnexus/store`'s
* `readonlySnapshot` does by design.
*/
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
let cached: Readonly<S> | undefined;
let dirty = true;
const unsubscribe = source.subscribe(() => {
dirty = true;
});
return {
getSnapshot() {
if (dirty || cached === undefined) {
cached = source.snapshot();
dirty = false;
}
return cached;
},
dispose() {
unsubscribe();
},
};
}
/**
* Memoizes a selector over a cached snapshot. Without this, any mutation
* re-renders every island bound to the store, because snapshots are whole-state.
*/
export function createSelectorCache<S extends object, R>(
getSnapshot: () => Readonly<S>,
selector: (state: Readonly<S>) => R,
): () => R {
let lastSnapshot: Readonly<S> | undefined;
let lastResult: R;
let initialized = false;
return () => {
const snapshot = getSnapshot();
if (!initialized || snapshot !== lastSnapshot) {
const next = selector(snapshot);
if (!initialized || !Object.is(next, lastResult)) lastResult = next;
lastSnapshot = snapshot;
initialized = true;
}
return lastResult;
};
}
- Step 5: Run test to verify it passes
Run: bun test packages/react/test/snapshot-cache.test.ts
Expected: PASS (3 tests)
- Step 6: Commit
git add packages/react/package.json packages/react/src/snapshot-cache.ts packages/react/test/snapshot-cache.test.ts
git commit -m "feat(react): add referentially-stable snapshot and selector caches"
Task 2: Store bridge hook
Files:
- Create:
packages/react/src/store-bridge.ts - Create:
packages/react/src/index.ts - Modify:
package.json(root) — addreact,react-domto devDependencies for tests - Test:
packages/react/test/store-bridge.test.ts
Interfaces:
-
Consumes:
createSnapshotCache,createSelectorCachefrom Task 1. -
Produces:
useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): RsetStoreResolver(resolver: StoreResolver | null, names?: string[]): void— test seam;namespopulates the "available stores" error messagetype StoreResolver = (name: string) => IslandStore<object> | undefinedinterface IslandStore<S extends object> { snapshot(): Readonly<S>; subscribe(listener: () => void): () => void; actions: Record<string, (...args: any[]) => unknown> }
-
Step 1: Install React as a dev dependency
Run: bun add -D react@^19 react-dom@^19 @types/react @types/react-dom
- Step 2: Write the failing test
Create packages/react/test/store-bridge.test.ts:
import { expect, test } from "bun:test";
import { getStoreForTest, setStoreResolver } from "../src/store-bridge.ts";
function fakeStore(initial: { count: number }) {
let state = { ...initial };
const listeners = new Set<() => void>();
return {
snapshot: () => Object.freeze({ ...state }),
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
actions: {
increment: () => {
state = { count: state.count + 1 };
for (const listener of [...listeners]) listener();
},
},
};
}
test("resolves a registered store and caches its snapshot", () => {
const store = fakeStore({ count: 0 });
setStoreResolver((name) => (name === "counter" ? store : undefined));
const bound = getStoreForTest("counter");
expect(bound.getSnapshot()).toBe(bound.getSnapshot());
bound.store.actions.increment!();
expect(bound.getSnapshot().count).toBe(1);
setStoreResolver(null);
});
test("throws a helpful error for an unknown store name", () => {
setStoreResolver((name) => (name === "counter" ? fakeStore({ count: 0 }) : undefined));
expect(() => getStoreForTest("typo")).toThrow(/Unknown WRNexus store "typo"/);
expect(() => getStoreForTest("typo")).toThrow(/counter/);
setStoreResolver(null);
});
- Step 3: Run test to verify it fails
Run: bun test packages/react/test/store-bridge.test.ts
Expected: FAIL — cannot resolve ../src/store-bridge.ts
- Step 4: Write minimal implementation
Create packages/react/src/store-bridge.ts:
import { useDebugValue, useMemo, useSyncExternalStore } from "react";
import { createSelectorCache, createSnapshotCache, type SnapshotCache } from "./snapshot-cache.ts";
export interface IslandStore<S extends object> {
snapshot(): Readonly<S>;
subscribe(listener: () => void): () => void;
actions: Record<string, (...args: any[]) => unknown>;
}
export type StoreResolver = (name: string) => IslandStore<object> | undefined;
let resolver: StoreResolver | null = null;
let knownNames: string[] = [];
/** Registers how island stores are looked up. Set by the island runtime at mount. */
export function setStoreResolver(next: StoreResolver | null, names: string[] = []): void {
resolver = next;
knownNames = names;
}
export interface BoundStore<S extends object> {
store: IslandStore<S>;
getSnapshot: () => Readonly<S>;
cache: SnapshotCache<S>;
}
function resolveStore<S extends object>(name: string): BoundStore<S> {
if (!resolver) {
throw new Error(
`useWrnStore("${name}") was called before the island runtime registered any stores.`,
);
}
const store = resolver(name) as IslandStore<S> | undefined;
if (!store) {
const available = knownNames.length > 0 ? knownNames.join(", ") : "(none registered)";
throw new Error(`Unknown WRNexus store "${name}". Available stores: ${available}`);
}
const cache = createSnapshotCache<S>(store);
return { store, cache, getSnapshot: cache.getSnapshot };
}
/** Test seam — exercises resolution and caching without rendering React. */
export function getStoreForTest<S extends object>(name: string): BoundStore<S> {
return resolveStore<S>(name);
}
/**
* Reads a WRNexus store from inside a React island.
*
* Writes must go through `store.actions.*` from an event handler or effect —
* never during render, which would loop.
*/
export function useWrnStore<S extends object, R = Readonly<S>>(
name: string,
selector?: (state: Readonly<S>) => R,
): R {
const bound = useMemo(() => resolveStore<S>(name), [name]);
const read = useMemo(
() =>
selector
? createSelectorCache<S, R>(bound.getSnapshot, selector)
: (bound.getSnapshot as unknown as () => R),
[bound, selector],
);
const value = useSyncExternalStore(bound.store.subscribe, read, read);
useDebugValue(value);
return value;
}
/** Returns the action map for a store, for writes from handlers and effects. */
export function useWrnActions(name: string): Record<string, (...args: any[]) => unknown> {
return useMemo(() => resolveStore(name).store.actions, [name]);
}
Create packages/react/src/index.ts:
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 { IslandStore, StoreResolver } from "./store-bridge.ts";
- Step 5: Run test to verify it passes
Run: bun test packages/react/test/store-bridge.test.ts
Expected: PASS (2 tests)
- Step 6: Commit
git add packages/react/src/store-bridge.ts packages/react/src/index.ts packages/react/test/store-bridge.test.ts package.json bun.lock
git commit -m "feat(react): add useWrnStore bridge over useSyncExternalStore"
Task 3: Island marker codegen
Files:
- Create:
packages/compiler/src/island-codegen.ts - Test:
packages/compiler/test/island-codegen.test.ts
Interfaces:
-
Consumes:
escapeHtmlfrom@wrnexus/core(packages/core/src/security.ts). -
Produces:
type IslandStrategy = "only" | "load" | "visible" | "idle"parseIslandStrategy(directives: string[]): IslandStrategyserializeIslandProps(componentName: string, props: Record<string, unknown>): { json: string } | { diagnostic: IslandDiagnostic }renderIslandMarker(input: { name: string; strategy: IslandStrategy; propsJson: string }): stringinterface IslandDiagnostic { code: "WRN-ISLAND-PROPS"; message: string; severity: "error" }
-
Step 1: Write the failing test
Create packages/compiler/test/island-codegen.test.ts:
import { expect, test } from "bun:test";
import {
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "../src/island-codegen.ts";
test("defaults to the client-only strategy", () => {
expect(parseIslandStrategy([])).toBe("only");
expect(parseIslandStrategy(["client:visible"])).toBe("visible");
expect(parseIslandStrategy(["client:idle"])).toBe("idle");
expect(parseIslandStrategy(["client:load"])).toBe("load");
});
test("serializes JSON-safe props", () => {
const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] });
expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' });
});
test("rejects non-serializable props with WRN-ISLAND-PROPS", () => {
const result = serializeIslandProps("Chart", { onClick: () => {} });
expect(result).toHaveProperty("diagnostic");
const { diagnostic } = result as { diagnostic: { code: string; message: string } };
expect(diagnostic.code).toBe("WRN-ISLAND-PROPS");
expect(diagnostic.message).toContain("Chart");
expect(diagnostic.message).toContain("onClick");
});
test("renders a marker with escaped props", () => {
const html = renderIslandMarker({
name: "Chart",
strategy: "visible",
propsJson: '{"title":"a<b\\"c"}',
});
expect(html).toContain('data-wrn-island="Chart"');
expect(html).toContain('data-wrn-island-strategy="visible"');
expect(html).not.toContain('title":"a<b"c');
expect(html).toContain("<");
expect(html).toContain(""");
});
- Step 2: Run test to verify it fails
Run: bun test packages/compiler/test/island-codegen.test.ts
Expected: FAIL — cannot resolve ../src/island-codegen.ts
- Step 3: Write minimal implementation
Create packages/compiler/src/island-codegen.ts:
import { escapeHtml } from "@wrnexus/core";
export type IslandStrategy = "only" | "load" | "visible" | "idle";
export interface IslandDiagnostic {
code: "WRN-ISLAND-PROPS";
message: string;
severity: "error";
}
const STRATEGIES: Record<string, IslandStrategy> = {
"client:only": "only",
"client:load": "load",
"client:visible": "visible",
"client:idle": "idle",
};
export function parseIslandStrategy(directives: string[]): IslandStrategy {
for (const directive of directives) {
const match = STRATEGIES[directive];
if (match) return match;
}
return "only";
}
function unsupportedProp(value: unknown): boolean {
const type = typeof value;
if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
return true;
}
if (value === null || type !== "object") return false;
const proto = Object.getPrototypeOf(value);
if (Array.isArray(value)) return (value as unknown[]).some(unsupportedProp);
if (proto !== Object.prototype && proto !== null) return true;
return Object.values(value as Record<string, unknown>).some(unsupportedProp);
}
export function serializeIslandProps(
componentName: string,
props: Record<string, unknown>,
): { json: string } | { diagnostic: IslandDiagnostic } {
const offenders = Object.entries(props)
.filter(([, value]) => unsupportedProp(value))
.map(([key]) => key);
if (offenders.length > 0) {
return {
diagnostic: {
code: "WRN-ISLAND-PROPS",
severity: "error",
message:
`Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
`Island props cross a serialization boundary and must be JSON-safe ` +
`(no functions, symbols, bigints, undefined, or class instances).`,
},
};
}
return { json: JSON.stringify(props) };
}
export function renderIslandMarker(input: {
name: string;
strategy: IslandStrategy;
propsJson: string;
}): string {
return (
`<div data-wrn-island="${escapeHtml(input.name)}"` +
` data-wrn-island-strategy="${input.strategy}"` +
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
);
}
- Step 4: Run test to verify it passes
Run: bun test packages/compiler/test/island-codegen.test.ts
Expected: PASS (4 tests)
- Step 5: Commit
git add packages/compiler/src/island-codegen.ts packages/compiler/test/island-codegen.test.ts
git commit -m "feat(compiler): add island marker codegen and props contract"
Task 4: Resolve .tsx imports as islands
Files:
- Modify:
packages/compiler/src/import-resolver.ts - Test:
packages/compiler/test/island-resolution.test.ts
Interfaces:
-
Consumes: nothing from prior tasks.
-
Produces:
ResolvedImportgains an optionalkind?: "island"field, set when the resolved path ends in.tsx. -
Step 1: Write the failing test
Create packages/compiler/test/island-resolution.test.ts:
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveWrnImport } from "../src/import-resolver.ts";
function appWith(files: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), "wrnexus-island-"));
mkdirSync(join(root, "app"), { recursive: true });
for (const [name, contents] of Object.entries(files)) {
writeFileSync(join(root, "app", name), contents);
}
return root;
}
test("resolves a .tsx import and tags it as an island", () => {
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
const result = resolveWrnImport(
{ source: "./Chart", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("Chart.tsx");
expect(result.kind).toBe("island");
});
test("does not tag a .ts import as an island", () => {
const root = appWith({ "helper.ts": "export const value = 1;" });
const result = resolveWrnImport(
{ source: "./helper", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("helper.ts");
expect(result.kind).toBeUndefined();
});
test("prefers .wrn over .tsx when both exist", () => {
const root = appWith({
"Widget.wrn": "<template></template>",
"Widget.tsx": "export default function Widget() { return null; }",
});
const result = resolveWrnImport(
{ source: "./Widget", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("Widget.wrn");
expect(result.kind).toBeUndefined();
});
- Step 2: Run test to verify it fails
Run: bun test packages/compiler/test/island-resolution.test.ts
Expected: FAIL — result.kind is undefined for the .tsx case
- Step 3: Add
.tsxto the candidate list
In packages/compiler/src/import-resolver.ts, extend candidates(). .wrn stays first so it keeps priority:
function candidates(path: string): string[] {
return extname(path)
? [path]
: [
path,
`${path}.wrn`,
`${path}.ts`,
`${path}.tsx`,
`${path}.d.ts`,
join(path, "index.wrn"),
join(path, "index.ts"),
join(path, "index.tsx"),
];
}
- Step 4: Tag island imports
In the same file, add kind to the interface:
export interface ResolvedImport {
declaration: StructuredImportDecl;
resolved?: string;
kind?: "island";
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
}
Then change the success return inside resolveWrnImport from return { declaration, resolved: realpathSync(found) }; to:
if (found) {
const resolved = realpathSync(found);
return resolved.endsWith(".tsx")
? { declaration, resolved, kind: "island" }
: { declaration, resolved };
}
- Step 5: Run test to verify it passes
Run: bun test packages/compiler/test/island-resolution.test.ts
Expected: PASS (3 tests)
- Step 6: Verify no existing compiler tests regressed
Run: bun test packages/compiler
Expected: PASS, no failures
- Step 7: Commit
git add packages/compiler/src/import-resolver.ts packages/compiler/test/island-resolution.test.ts
git commit -m "feat(compiler): resolve .tsx imports and tag them as islands"
Task 5: Island error boundary
Files:
- Create:
packages/react/src/error-boundary.tsx - Test:
packages/react/test/error-boundary.test.tsx
Interfaces:
-
Consumes: nothing from prior tasks.
-
Produces:
IslandErrorBoundary— a React component with props{ name: string; development: boolean; children: ReactNode }. -
Step 1: Write the failing test
Create packages/react/test/error-boundary.test.tsx:
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { IslandErrorBoundary } from "../src/error-boundary.tsx";
function Boom(): never {
throw new Error("chart exploded");
}
test("renders children when nothing throws", () => {
const html = renderToStaticMarkup(
<IslandErrorBoundary name="Chart" development={false}>
<p>ok</p>
</IslandErrorBoundary>,
);
expect(html).toBe("<p>ok</p>");
});
test("contains a thrown error and shows details in development", () => {
const html = renderToStaticMarkup(
<IslandErrorBoundary name="Chart" development>
<Boom />
</IslandErrorBoundary>,
);
expect(html).toContain("Chart");
expect(html).toContain("chart exploded");
});
test("renders nothing in production when an island throws", () => {
const html = renderToStaticMarkup(
<IslandErrorBoundary name="Chart" development={false}>
<Boom />
</IslandErrorBoundary>,
);
expect(html).toBe("");
});
Note: react-dom/server is used only in this test to exercise the boundary synchronously. Island runtime code must never import it.
- Step 2: Run test to verify it fails
Run: bun test packages/react/test/error-boundary.test.tsx
Expected: FAIL — cannot resolve ../src/error-boundary.tsx
- Step 3: Write minimal implementation
Create packages/react/src/error-boundary.tsx:
import { Component, type ErrorInfo, type ReactNode } from "react";
export interface IslandErrorBoundaryProps {
name: string;
development: boolean;
children: ReactNode;
}
interface IslandErrorBoundaryState {
error: Error | null;
}
/**
* Contains island failures locally: a crashed island must never blank the
* surrounding server-rendered page.
*/
export class IslandErrorBoundary extends Component<
IslandErrorBoundaryProps,
IslandErrorBoundaryState
> {
override state: IslandErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): IslandErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(`[wrnexus] island '${this.props.name}' failed to render`, error, info);
}
override render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
if (!this.props.development) return null;
return (
<div data-wrn-island-error={this.props.name} style={{ padding: "0.75rem" }}>
<strong>{`Island '${this.props.name}' failed`}</strong>
<pre>{error.stack ?? error.message}</pre>
</div>
);
}
}
- Step 4: Run test to verify it passes
Run: bun test packages/react/test/error-boundary.test.tsx
Expected: PASS (3 tests)
- Step 5: Commit
git add packages/react/src/error-boundary.tsx packages/react/test/error-boundary.test.tsx
git commit -m "feat(react): add per-island error boundary"
Task 6: Island runtime — mount, strategies, unmount
Files:
- Create:
packages/react/src/island-runtime.ts - Modify:
packages/react/src/index.ts - Test:
packages/react/test/island-runtime.test.ts
Interfaces:
-
Consumes:
IslandErrorBoundary(Task 5),setStoreResolver(Task 2). -
Produces:
mountIslands(root: ParentNode, options: MountOptions): Promise<void>unmountIslands(root: ParentNode): voidinterface MountOptions { loader: (name: string) => Promise<{ default: ComponentType<any> }>; development?: boolean }islandRootCount(): number— test seam for leak assertions
-
Step 1: Write the failing test
Create packages/react/test/island-runtime.test.ts:
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { createElement } from "react";
import { islandRootCount, mountIslands, unmountIslands } from "../src/island-runtime.ts";
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"),
});
test("mounts an island and passes deserialized props", async () => {
const window = domWith(
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
data-wrn-island-props='{"title":"Revenue"}'></div>`,
);
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(
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
data-wrn-island-props='{"title":"A"}'></div>`,
);
await mountIslands(window.document.body, { loader });
expect(islandRootCount()).toBe(1);
unmountIslands(window.document.body);
expect(islandRootCount()).toBe(0);
});
test("repeated mount/unmount cycles do not accumulate roots", async () => {
const window = domWith(
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
data-wrn-island-props='{}'></div>`,
);
for (let i = 0; i < 5; i += 1) {
await mountIslands(window.document.body, { loader });
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 mountIslands(window.document.body, { loader });
expect(islandRootCount()).toBe(0);
});
- Step 2: Run test to verify it fails
Run: bun test packages/react/test/island-runtime.test.ts
Expected: FAIL — cannot resolve ../src/island-runtime.ts
- Step 3: Write minimal implementation
Create packages/react/src/island-runtime.ts:
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);
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;
}
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 && !root.contains(element)) continue;
try {
reactRoot.unmount();
} catch (error) {
console.error("[wrnexus] island failed to unmount cleanly", error);
}
roots.delete(element);
}
}
- Step 4: Export the runtime
Append to packages/react/src/index.ts:
export { IslandErrorBoundary } from "./error-boundary.tsx";
export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts";
export type { MountOptions } from "./island-runtime.ts";
- Step 5: Run test to verify it passes
Run: bun test packages/react/test/island-runtime.test.ts
Expected: PASS (4 tests)
- Step 6: Commit
git add packages/react/src/island-runtime.ts packages/react/src/index.ts packages/react/test/island-runtime.test.ts
git commit -m "feat(react): add island mount strategies and navigation-safe unmount"
Task 7: Island bundling with a shared React chunk
Files:
- Create:
packages/compiler/src/island-bundle.ts - Test:
packages/compiler/test/island-bundle.test.ts
Interfaces:
-
Consumes: nothing from prior tasks.
-
Produces:
generateIslandEntry(input: { name: string; sourcePath: string }): stringbuildIslands(input: { islands: Array<{ name: string; sourcePath: string }>; outDir: string }): Promise<IslandBuildResult>interface IslandBuildResult { assets: Array<{ name: string; hash: string; path: string }>; sharedChunks: string[] }assertReactAvailable(appRoot: string): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null
-
Step 1: Write the failing test
Create packages/compiler/test/island-bundle.test.ts:
import { expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { assertReactAvailable, generateIslandEntry } from "../src/island-bundle.ts";
test("generates an entry that registers the island by name", () => {
const entry = generateIslandEntry({ name: "Chart", sourcePath: "/app/Chart.tsx" });
expect(entry).toContain("/app/Chart.tsx");
expect(entry).toContain("Chart");
expect(entry).not.toContain("react-dom/server");
});
test("reports WRN-ISLAND-REACT-MISSING when react is not installed", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-noreact-"));
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
const diagnostic = assertReactAvailable(root);
expect(diagnostic?.code).toBe("WRN-ISLAND-REACT-MISSING");
expect(diagnostic?.message).toContain("bun add react react-dom");
});
test("returns null when react resolves", () => {
expect(assertReactAvailable(process.cwd())).toBeNull();
});
- Step 2: Run test to verify it fails
Run: bun test packages/compiler/test/island-bundle.test.ts
Expected: FAIL — cannot resolve ../src/island-bundle.ts
- Step 3: Write minimal implementation
Create packages/compiler/src/island-bundle.ts:
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { join } from "node:path";
export interface IslandInput {
name: string;
sourcePath: string;
}
export interface IslandBuildResult {
assets: Array<{ name: string; hash: string; path: string }>;
sharedChunks: string[];
}
/** Generates the per-island browser entry. Never imports react-dom/server. */
export function generateIslandEntry(input: IslandInput): string {
return [
`import Component from ${JSON.stringify(input.sourcePath)};`,
`export const name = ${JSON.stringify(input.name)};`,
`export default Component;`,
].join("\n");
}
export function assertReactAvailable(
appRoot: string,
): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null {
const require = createRequire(join(appRoot, "package.json"));
try {
require.resolve("react");
require.resolve("react-dom");
return null;
} catch {
return {
code: "WRN-ISLAND-REACT-MISSING",
severity: "error",
message:
"This app imports a .tsx island but react and react-dom are not installed. " +
"Run: bun add react react-dom",
};
}
}
/**
* Bundles island entries. `splitting: true` is required so React is emitted
* once as a shared chunk rather than duplicated into every island.
*/
export async function buildIslands(input: {
islands: IslandInput[];
outDir: string;
}): Promise<IslandBuildResult> {
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
const result = await Bun.build({
entrypoints: input.islands.map((island) => island.sourcePath),
outdir: input.outDir,
target: "browser",
format: "esm",
splitting: true,
minify: true,
});
if (!result.success) {
throw new AggregateError(result.logs, "Island bundling failed");
}
const assets: IslandBuildResult["assets"] = [];
const sharedChunks: string[] = [];
for (const output of result.outputs) {
if (output.kind === "entry-point") {
const index = assets.length;
const island = input.islands[index]!;
assets.push({
name: island.name,
hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16),
path: output.path,
});
} else if (output.kind === "chunk") {
sharedChunks.push(output.path);
}
}
return { assets, sharedChunks };
}
- Step 4: Run test to verify it passes
Run: bun test packages/compiler/test/island-bundle.test.ts
Expected: PASS (3 tests)
- Step 5: Commit
git add packages/compiler/src/island-bundle.ts packages/compiler/test/island-bundle.test.ts
git commit -m "feat(compiler): bundle islands with a shared React chunk"
Task 8: Serve island assets in dev, prod, and static build
Files:
- Create:
packages/react/src/runtime-source.ts - Modify:
packages/dev-server/src/assets.ts - Modify:
packages/dev-server/src/prod.ts - Modify:
packages/cli/src/build.ts - Test:
packages/react/test/runtime-source.test.ts
Interfaces:
-
Consumes:
mountIslands,unmountIslands(Task 6). -
Produces:
getIslandRuntime(development?: boolean): string— browser JS served at/__wrnexus/islands.js, mirroringgetReactiveRuntimein@wrnexus/csr. -
Step 1: Write the failing test
Create packages/react/test/runtime-source.test.ts:
import { expect, test } from "bun:test";
import { getIslandRuntime } from "../src/runtime-source.ts";
test("emits a runtime that bails out when no island markers exist", () => {
const source = getIslandRuntime(false);
expect(source).toContain("data-wrn-island");
expect(source).toContain("/__wrnexus/island/");
});
test("registers a navigation hook so islands unmount on route change", () => {
expect(getIslandRuntime(false)).toContain("__wrnexusUnmountIslands");
});
test("never references react-dom/server", () => {
expect(getIslandRuntime(true)).not.toContain("react-dom/server");
});
- Step 2: Run test to verify it fails
Run: bun test packages/react/test/runtime-source.test.ts
Expected: FAIL — cannot resolve ../src/runtime-source.ts
- Step 3: Write minimal implementation
Create packages/react/src/runtime-source.ts:
/**
* The island bootstrap served at `/__wrnexus/islands.js`.
*
* Mirrors the `@wrnexus/csr` pattern: this file is only ever fetched when a
* `data-wrn-island` marker is present, so island-free pages download nothing.
*/
export function getIslandRuntime(development = false): string {
return `
(function () {
var mounted = null;
function loader(name) {
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js");
}
function boot() {
if (!document.querySelector("[data-wrn-island]")) return;
import("/__wrnexus/island/runtime.js").then(function (runtime) {
mounted = runtime;
runtime.mountIslands(document, { loader: loader, development: ${development} });
window.__wrnexusUnmountIslands = function (root) {
runtime.unmountIslands(root || document);
};
}).catch(function (error) {
console.error("[wrnexus] failed to load the island runtime", error);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
`;
}
- Step 4: Run test to verify it passes
Run: bun test packages/react/test/runtime-source.test.ts
Expected: PASS (3 tests)
- Step 5: Register the dev route
In packages/dev-server/src/assets.ts, add the import alongside the existing @wrnexus/csr imports:
import { getIslandRuntime } from "@wrnexus/react/runtime";
Then inside serve(pathname), next to the other /__wrnexus/*.js lines:
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
The bootstrap dynamically imports /__wrnexus/island/runtime.js (the bundled mount runtime) and /__wrnexus/island/<name>.js (per-island bundles). Both live under the /__wrnexus/island/ prefix, so add one prefix handler beside the existing /__wrnexus/client/ handler at the top of serve(pathname):
if (pathname.startsWith("/__wrnexus/island/")) {
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
}
where serveIslandArtifact reads from the island build output directory produced by buildIslands (Task 7), mirroring how serveWrnBrowserArtifact serves /__wrnexus/client/.
- Step 6: Register the prod route
In packages/dev-server/src/prod.ts, add the same import, then beside the existing getReactiveRuntime() route:
if (pathname === "/__wrnexus/islands.js")
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
Add the same /__wrnexus/island/ prefix handler here, serving the built island assets from the production output directory.
- Step 7: Emit the assets in static builds
packages/cli/src/build.ts emits the other runtimes around line 482 (getReactiveRuntime(), getComponentControllerRuntime()). Add the import and, in that same block, emit two things:
getIslandRuntime()written to__wrnexus/islands.js.- The output of
buildIslands({ islands, outDir })(Task 7) written under__wrnexus/island/, whereislandsis collected from resolved imports taggedkind: "island"(Task 4).
Skip both entirely when the app has no island imports — a zero-island build must produce no island assets, which Task 10 asserts.
- Step 8: Verify nothing regressed
Run: bun test packages/dev-server packages/cli packages/react
Expected: PASS, no failures
- Step 9: Commit
git add packages/react/src/runtime-source.ts packages/react/test/runtime-source.test.ts packages/dev-server/src/assets.ts packages/dev-server/src/prod.ts packages/cli/src/build.ts
git commit -m "feat(islands): serve the island runtime in dev, prod, and static builds"
Task 9: Route classification
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
Files:
- Modify:
packages/compiler/src/analysis.ts - Test:
packages/compiler/test/island-classification.test.ts
Interfaces:
-
Consumes:
ResolvedImport.kind(Task 4). -
Produces:
routeNeedsIslands(imports: ResolvedImport[]): boolean, exported fromanalysis.ts. -
Step 1: Write the failing test
Create packages/compiler/test/island-classification.test.ts:
import { expect, test } from "bun:test";
import { routeNeedsIslands } from "../src/analysis.ts";
test("a route with an island import needs client JavaScript", () => {
expect(
routeNeedsIslands([
{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" },
{ declaration: { source: "./Chart" } as any, resolved: "/app/Chart.tsx", kind: "island" },
]),
).toBe(true);
});
test("a route with no island imports stays zero-JS", () => {
expect(
routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
).toBe(false);
});
test("an empty import list stays zero-JS", () => {
expect(routeNeedsIslands([])).toBe(false);
});
- Step 2: Run test to verify it fails
Run: bun test packages/compiler/test/island-classification.test.ts
Expected: FAIL — routeNeedsIslands is not exported
- Step 3: Write minimal implementation
Add to packages/compiler/src/analysis.ts:
import type { ResolvedImport } from "./import-resolver.ts";
/**
* A route containing an island ships React and must be classified
* static-interactive rather than static, so the zero-JS reporting stays honest.
*/
export function routeNeedsIslands(imports: ResolvedImport[]): boolean {
return imports.some((entry) => entry.kind === "island");
}
Then, in the existing route-classification path in analysis.ts, treat a route where routeNeedsIslands(...) is true as static-interactive rather than static, following the surrounding classification code.
- Step 4: Run test to verify it passes
Run: bun test packages/compiler/test/island-classification.test.ts
Expected: PASS (3 tests)
- Step 5: Verify no compiler regressions
Run: bun test packages/compiler
Expected: PASS, no failures
- Step 6: Commit
git add packages/compiler/src/analysis.ts packages/compiler/test/island-classification.test.ts
git commit -m "feat(compiler): classify island routes as static-interactive"
Task 10: Integration guards
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
Files:
- Create:
examples/basic-app/app/islands/Counter.tsx - Test:
packages/compiler/test/island-integration.test.ts
Interfaces:
-
Consumes:
buildIslands(Task 7),renderIslandMarker(Task 3),routeNeedsIslands(Task 9). -
Produces: nothing consumed downstream.
-
Step 1: Create a real island fixture
Create examples/basic-app/app/islands/Counter.tsx:
import { useState } from "react";
export default function Counter({ start = 0 }: { start?: number }) {
const [count, setCount] = useState(start);
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
{`clicked ${count}`}
</button>
);
}
- Step 2: Write the failing test
Create packages/compiler/test/island-integration.test.ts:
import { expect, test } from "bun:test";
import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { buildIslands } from "../src/island-bundle.ts";
import { routeNeedsIslands } from "../src/analysis.ts";
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
test("a route with no islands ships zero framework JavaScript", async () => {
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
const result = await buildIslands({ islands: [], outDir });
expect(result.assets).toHaveLength(0);
expect(result.sharedChunks).toHaveLength(0);
expect(readdirSync(outDir)).toHaveLength(0);
expect(routeNeedsIslands([])).toBe(false);
});
test("a page with multiple islands ships React exactly once", async () => {
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-shared-"));
const result = await buildIslands({
islands: [
{ name: "CounterA", sourcePath: COUNTER },
{ name: "CounterB", sourcePath: COUNTER },
],
outDir,
});
const bundles = readdirSync(outDir)
.filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(outDir, file), "utf8"));
const withReactInternals = bundles.filter(
(source) => source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
);
expect(result.assets).toHaveLength(2);
expect(withReactInternals.length).toBeLessThanOrEqual(1);
});
- Step 3: Run test to verify it fails
Run: bun test packages/compiler/test/island-integration.test.ts
Expected: FAIL — Counter.tsx fixture missing, or React duplicated across bundles if splitting is misconfigured
- Step 4: Make the tests pass
If the shared-chunk assertion fails, confirm splitting: true is set in buildIslands (Task 7) and that both entrypoints resolve react to the same path. Do not weaken the assertion.
- Step 5: Run test to verify it passes
Run: bun test packages/compiler/test/island-integration.test.ts
Expected: PASS (2 tests)
- Step 6: Run the full production gate
Run: bun run check:production
Expected: PASS
- Step 7: Commit
git add examples/basic-app/app/islands/Counter.tsx packages/compiler/test/island-integration.test.ts
git commit -m "test(islands): guard zero-JS routes and single-React bundling"
Task 11: Write-during-render guard
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
Files:
- Create:
packages/react/src/render-phase.ts - Modify:
packages/react/src/store-bridge.ts(wrap actions inuseWrnActions) - Modify:
packages/react/src/index.ts - Test:
packages/react/test/render-phase.test.ts
Interfaces:
-
Consumes:
useWrnActions(Task 2). -
Produces:
beginRenderPhase(): voidisRenderPhase(): booleanguardAction<T extends (...args: any[]) => unknown>(storeName: string, actionName: string, fn: T, development: boolean): T
-
Step 1: Write the failing test
Create packages/react/test/render-phase.test.ts:
import { expect, test } from "bun:test";
import { beginRenderPhase, guardAction, isRenderPhase } from "../src/render-phase.ts";
test("the render phase clears after the synchronous render completes", async () => {
beginRenderPhase();
expect(isRenderPhase()).toBe(true);
await Promise.resolve();
expect(isRenderPhase()).toBe(false);
});
test("a guarded action throws in development when called during render", () => {
const guarded = guardAction("cart", "addItem", () => "ok", true);
beginRenderPhase();
expect(guarded).toThrow(/cart\.addItem/);
expect(guarded).toThrow(/event handler or effect/);
});
test("a guarded action runs normally outside render", async () => {
const guarded = guardAction("cart", "addItem", () => "ok", true);
beginRenderPhase();
await Promise.resolve();
expect(guarded()).toBe("ok");
});
test("the guard is inert in production", () => {
const guarded = guardAction("cart", "addItem", () => "ok", false);
beginRenderPhase();
expect(guarded()).toBe("ok");
});
- Step 2: Run test to verify it fails
Run: bun test packages/react/test/render-phase.test.ts
Expected: FAIL — cannot resolve ../src/render-phase.ts
- Step 3: Write minimal implementation
Create packages/react/src/render-phase.ts:
let rendering = false;
/**
* Marks the start of a synchronous React render. Cleared on the next
* microtask, since React's render phase is synchronous.
*/
export function beginRenderPhase(): void {
rendering = true;
queueMicrotask(() => {
rendering = false;
});
}
export function isRenderPhase(): boolean {
return rendering;
}
/**
* Wraps a store action so calling it during render fails loudly in dev.
* Writing during render loops: write -> action -> notify -> re-render -> write.
*/
export function guardAction<T extends (...args: any[]) => unknown>(
storeName: string,
actionName: string,
fn: T,
development: boolean,
): T {
if (!development) return fn;
return ((...args: unknown[]) => {
if (rendering) {
throw new Error(
`Island called ${storeName}.${actionName}() during render. ` +
`Store writes must happen in an event handler or effect, never during render.`,
);
}
return fn(...args);
}) as T;
}
- Step 4: Wrap actions in the store bridge
In packages/react/src/store-bridge.ts, import the guard:
import { guardAction } from "./render-phase.ts";
and replace the body of useWrnActions with a guarded map:
export function useWrnActions(
name: string,
development = false,
): Record<string, (...args: any[]) => unknown> {
return useMemo(() => {
const actions = resolveStore(name).store.actions;
return Object.fromEntries(
Object.entries(actions).map(([actionName, fn]) => [
actionName,
guardAction(name, actionName, fn, development),
]),
);
}, [name, development]);
}
- Step 5: Call
beginRenderPhasefrom the error boundary
In packages/react/src/error-boundary.tsx, import beginRenderPhase and call it as the first statement of render(), so every island's render is marked:
override render(): ReactNode {
beginRenderPhase();
const { error } = this.state;
- Step 6: Export the guard
Append to packages/react/src/index.ts:
export { beginRenderPhase, guardAction, isRenderPhase } from "./render-phase.ts";
- Step 7: Run tests to verify they pass
Run: bun test packages/react
Expected: PASS, no failures
- Step 8: Commit
git add packages/react/src/render-phase.ts packages/react/src/store-bridge.ts packages/react/src/error-boundary.tsx packages/react/src/index.ts packages/react/test/render-phase.test.ts
git commit -m "feat(react): fail loudly on store writes during island render"
Task 12: HMR remount
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
Files:
- Modify:
packages/react/src/island-runtime.ts - Modify:
packages/react/src/runtime-source.ts - Modify:
packages/react/src/index.ts - Test:
packages/react/test/island-hmr.test.ts
Interfaces:
-
Consumes:
mountIslands,unmountIslands(Task 6). -
Produces:
remountIslands(root: ParentNode, options: MountOptions): Promise<void> -
Step 1: Write the failing test
Create packages/react/test/island-hmr.test.ts:
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { createElement } from "react";
import { islandRootCount, mountIslands, remountIslands } from "../src/island-runtime.ts";
test("remount replaces island output without leaking roots", async () => {
const window = new Window();
window.document.body.innerHTML = `<div data-wrn-island="Chart" data-wrn-island-strategy="only"
data-wrn-island-props='{}'></div>`;
(globalThis as any).window = window;
(globalThis as any).document = window.document;
const first = async () => ({ default: () => createElement("span", null, "v1") });
const second = async () => ({ default: () => createElement("span", null, "v2") });
await mountIslands(window.document.body, { loader: first });
expect(window.document.body.textContent).toContain("v1");
expect(islandRootCount()).toBe(1);
await remountIslands(window.document.body, { loader: second });
expect(window.document.body.textContent).toContain("v2");
expect(islandRootCount()).toBe(1);
});
- Step 2: Run test to verify it fails
Run: bun test packages/react/test/island-hmr.test.ts
Expected: FAIL — remountIslands is not exported
- Step 3: Write minimal implementation
Append to packages/react/src/island-runtime.ts:
/**
* Dev-only: dispose and re-create island roots after a source change.
* Island state resets by design; Fast Refresh is out of scope for v1.
*/
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
unmountIslands(root);
await mountIslands(root, options);
}
unmountIslands clears each element from the roots map, so the subsequent mountIslands call does not short-circuit on the roots.has(element) guard.
- Step 4: Expose the HMR hook in the browser runtime
In packages/react/src/runtime-source.ts, inside the .then(function (runtime) { ... }) block, add alongside window.__wrnexusUnmountIslands:
window.__wrnexusRemountIslands = function (root) {
return runtime.remountIslands(root || document, {
loader: loader,
development: ${development}
});
};
- Step 5: Export it
Append to packages/react/src/index.ts:
export { remountIslands } from "./island-runtime.ts";
- Step 6: Run tests to verify they pass
Run: bun test packages/react
Expected: PASS, no failures
- Step 7: Commit
git add packages/react/src/island-runtime.ts packages/react/src/runtime-source.ts packages/react/src/index.ts packages/react/test/island-hmr.test.ts
git commit -m "feat(react): remount islands on hot module replacement"
Deferred to v2 (not in this plan)
- SSR opt-in (
renderToString+hydrateRoot) bind:syntax sugar over the store bridge- React Fast Refresh (the concrete trigger for reconsidering Vite or esbuild)