# 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`.
- `react` and `react-dom` are **optional peer dependencies** of `@wrnexus/react`. They must never become dependencies of `core`, `csr`, `store`, `compiler`, or `dev-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/server` import anywhere.
- `@wrnexus/store` source 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 is `only`.
- Asset routes: `/__wrnexus/islands.js` and `/__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`:

```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`:

```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`:

```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**

```bash
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) — add `react`, `react-dom` to devDependencies for tests
- Test: `packages/react/test/store-bridge.test.ts`

**Interfaces:**
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
- Produces:
  - `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
  - `setStoreResolver(resolver: StoreResolver | null, names?: string[]): void` — test seam; `names` populates the "available stores" error message
  - `type StoreResolver = (name: string) => IslandStore<object> | undefined`
  - `interface 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`:

```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`:

```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`:

```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**

```bash
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: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
- Produces:
  - `type IslandStrategy = "only" | "load" | "visible" | "idle"`
  - `parseIslandStrategy(directives: string[]): IslandStrategy`
  - `serializeIslandProps(componentName: string, props: Record<string, unknown>): { json: string } | { diagnostic: IslandDiagnostic }`
  - `renderIslandMarker(input: { name: string; strategy: IslandStrategy; propsJson: string }): string`
  - `interface IslandDiagnostic { code: "WRN-ISLAND-PROPS"; message: string; severity: "error" }`

- [ ] **Step 1: Write the failing test**

Create `packages/compiler/test/island-codegen.test.ts`:

```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("&lt;");
  expect(html).toContain("&quot;");
});
```

- [ ] **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`:

```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**

```bash
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: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.

- [ ] **Step 1: Write the failing test**

Create `packages/compiler/test/island-resolution.test.ts`:

```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 `.tsx` to the candidate list**

In `packages/compiler/src/import-resolver.ts`, extend `candidates()`. `.wrn` stays first so it keeps priority:

```ts
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:

```ts
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:

```ts
  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**

```bash
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`:

```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`:

```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**

```bash
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): void`
  - `interface 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`:

```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`:

```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`:

```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**

```bash
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 }): string`
  - `buildIslands(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`:

```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`:

```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**

```bash
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`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.

- [ ] **Step 1: Write the failing test**

Create `packages/react/test/runtime-source.test.ts`:

```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`:

```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:

```ts
import { getIslandRuntime } from "@wrnexus/react/runtime";
```

Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines:

```ts
      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)`:

```ts
      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:

```ts
        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:

1. `getIslandRuntime()` written to `__wrnexus/islands.js`.
2. The output of `buildIslands({ islands, outDir })` (Task 7) written under `__wrnexus/island/`, where `islands` is collected from resolved imports tagged `kind: "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**

```bash
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 from `analysis.ts`.

- [ ] **Step 1: Write the failing test**

Create `packages/compiler/test/island-classification.test.ts`:

```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`:

```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**

```bash
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`:

```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`:

```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**

```bash
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 in `useWrnActions`)
- Modify: `packages/react/src/index.ts`
- Test: `packages/react/test/render-phase.test.ts`

**Interfaces:**
- Consumes: `useWrnActions` (Task 2).
- Produces:
  - `beginRenderPhase(): void`
  - `isRenderPhase(): boolean`
  - `guardAction<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`:

```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`:

```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:

```ts
import { guardAction } from "./render-phase.ts";
```

and replace the body of `useWrnActions` with a guarded map:

```ts
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 `beginRenderPhase` from 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:

```tsx
  override render(): ReactNode {
    beginRenderPhase();
    const { error } = this.state;
```

- [ ] **Step 6: Export the guard**

Append to `packages/react/src/index.ts`:

```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**

```bash
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`:

```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`:

```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`:

```js
      window.__wrnexusRemountIslands = function (root) {
        return runtime.remountIslands(root || document, {
          loader: loader,
          development: ${development}
        });
      };
```

- [ ] **Step 5: Export it**

Append to `packages/react/src/index.ts`:

```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**

```bash
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)
