diff --git a/docs/superpowers/plans/2026-08-18-react-islands.md b/docs/superpowers/plans/2026-08-18-react-islands.md new file mode 100644 index 00000000..e540bd8f --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-react-islands.md @@ -0,0 +1,1834 @@ +# 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/.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(source: SnapshotSource): SnapshotCache` + - `interface SnapshotSource { snapshot(): Readonly; subscribe(listener: () => void): () => void }` + - `interface SnapshotCache { getSnapshot(): Readonly; dispose(): void }` + - `createSelectorCache(getSnapshot: () => Readonly, selector: (state: Readonly) => 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 { + snapshot(): Readonly; + subscribe(listener: () => void): () => void; +} + +export interface SnapshotCache { + getSnapshot(): Readonly; + 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( + source: SnapshotSource, +): SnapshotCache { + let cached: Readonly | 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( + getSnapshot: () => Readonly, + selector: (state: Readonly) => R, +): () => R { + let lastSnapshot: Readonly | 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>(name: string, selector?: (state: Readonly) => R): R` + - `setStoreResolver(resolver: StoreResolver | null, names?: string[]): void` — test seam; `names` populates the "available stores" error message + - `type StoreResolver = (name: string) => IslandStore | undefined` + - `interface IslandStore { snapshot(): Readonly; subscribe(listener: () => void): () => void; actions: Record 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 { + snapshot(): Readonly; + subscribe(listener: () => void): () => void; + actions: Record unknown>; +} + +export type StoreResolver = (name: string) => IslandStore | 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 { + store: IslandStore; + getSnapshot: () => Readonly; + cache: SnapshotCache; +} + +function resolveStore(name: string): BoundStore { + if (!resolver) { + throw new Error( + `useWrnStore("${name}") was called before the island runtime registered any stores.`, + ); + } + const store = resolver(name) as IslandStore | 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(store); + return { store, cache, getSnapshot: cache.getSnapshot }; +} + +/** Test seam — exercises resolution and caching without rendering React. */ +export function getStoreForTest(name: string): BoundStore { + return resolveStore(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>( + name: string, + selector?: (state: Readonly) => R, +): R { + const bound = useMemo(() => resolveStore(name), [name]); + const read = useMemo( + () => + selector + ? createSelectorCache(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 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): { 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 = { + "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).some(unsupportedProp); +} + +export function serializeIslandProps( + componentName: string, + props: Record, +): { 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 ( + `
` + ); +} +``` + +- [ ] **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) { + 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": "", + "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( + +

ok

+
, + ); + expect(html).toBe("

ok

"); +}); + +test("contains a thrown error and shows details in development", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Chart"); + expect(html).toContain("chart exploded"); +}); + +test("renders nothing in production when an island throws", () => { + const html = renderToStaticMarkup( + + + , + ); + 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 ( +
+ {`Island '${this.props.name}' failed`} +
{error.stack ?? error.message}
+
+ ); + } +} +``` + +- [ ] **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` + - `unmountIslands(root: ParentNode): void` + - `interface MountOptions { loader: (name: string) => Promise<{ default: ComponentType }>; 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( + `
`, + ); + + 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( + `
`, + ); + + 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( + `
`, + ); + + 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(`

plain server html

`); + 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 }>; + development?: boolean; +} + +const roots = new Map(); + +export function islandRootCount(): number { + return roots.size; +} + +function readProps(element: Element): Record { + const raw = element.getAttribute("data-wrn-island-props"); + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch (error) { + console.error("[wrnexus] island props were not valid JSON", error); + return {}; + } +} + +function whenReady(element: Element, strategy: string): Promise { + 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 { + 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; + 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 { + 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` + - `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 { + 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/.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 ( + + ); +} +``` + +- [ ] **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 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 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 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` + +- [ ] **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 = + `
`; + (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 { + 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)