# React Islands for WRNexus — Design **Date:** 2026-08-18 **Status:** Approved for implementation **Scope:** Add opt-in React islands to WRNexus without altering the existing SSR-first rendering model. ## Goal Give WRNexus authors access to the npm React ecosystem — charts, editors, date pickers, drag-and-drop, maps — without rebuilding those components natively and without adopting React as the framework's rendering model. This is explicitly **not** a migration path toward React, and not a replacement for `.wrn` components. Islands are a consumption path for third-party components. ### Non-goals - Server-rendering islands (deferred; see "Deferred to v2"). - React Fast Refresh. - `bind:` syntax sugar for store binding. - Replacing the `.wrn` authoring format. ## Guiding constraint WRNexus's differentiator is that non-interactive routes ship **zero** framework JavaScript. Every decision below is subordinate to preserving that. An app that uses no islands must be byte-for-byte unchanged, and a route with no islands must ship no React. ## Decisions | Question | Decision | |---|---| | Purpose | npm ecosystem access | | Server rendering | Client-only by default; SSR opt-in deferred to v2 | | Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `` | | Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) | | Bundling | Extend the existing Bun pipeline | | Packaging | New isolated package `@wrnexus/react` | ## Architecture ### Package boundary All React-specific code lives in a new package, `@wrnexus/react`. `react` and `react-dom` are declared as **optional peer dependencies**, so both the bundle cost and the dependency itself land only on apps that import a `.tsx` island. This boundary is deliberate: React concerns must never leak into `core`, `csr`, `store`, or `compiler` beyond the narrow, explicitly enumerated hooks below. If islands do not earn their keep, the feature is removed by deleting one package and reverting a small number of tagged integration points. ### Island lifecycle The compiler emits a placeholder element carrying `data-wrn-island` (name, strategy, serialized props) alongside the existing `data-wrn-scope` marker. The island runtime is lazy-loaded using the same marker-presence pattern as `loadComponentControllers` in `packages/csr/src/index.ts` — fetched only if a `data-wrn-island` marker exists in the document. A page with no islands downloads nothing, including React. Mount strategies: - `client:only` (default) — mount via `createRoot` once the bundle arrives. - `client:load` — mount on document load. - `client:visible` — mount via `IntersectionObserver`. - `client:idle` — mount on `requestIdleCallback`. **Unmount is mandatory.** WRNexus has client-side navigation (`packages/csr/src/nav-runtime.ts`). Island roots are tracked per scope and explicitly `root.unmount()`-ed on route change. Omitting this leaks React roots, detached DOM, and store subscriptions on every navigation. ### Asset serving Two additions following the existing `/__wrnexus/*` convention: - `/__wrnexus/islands.js` — the mount runtime. - `/__wrnexus/island/.js` — per-island bundles, content-hashed. Registered in the three existing locations: - `packages/dev-server/src/assets.ts` (dev) - `packages/dev-server/src/prod.ts` (prod) - `packages/cli/src/build.ts` (static build) ## Store bridge ### The hook `useWrnStore(name, selector?)`, built on `useSyncExternalStore`. `StoreInstanceCore` (`packages/store/src/types.ts`) already provides both halves of React's external-store contract — `subscribe(listener) => unsubscribe` and `snapshot()` — so the adapter is thin. Islands resolve the browser container via `browserStoreContainer()` from `packages/store/src/client.ts`, reading the store already hydrated from the server render. No separate island hydration channel is introduced. ### Snapshot caching (load-bearing) `readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a **new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a referentially identical value when nothing has changed; otherwise React throws *"The result of getSnapshot should be cached to avoid an infinite loop"* and spins. **The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds one cached snapshot per store instance, returns the same reference until the store's `subscribe` callback fires, then recomputes. Rationale: changing `readonlySnapshot` would alter semantics for all existing consumers and the current test suite in order to serve one new caller. Keeping the cache in the adapter leaves the store package untouched and confines this feature's blast radius to the new package. ### Selectors `snapshot()` returns whole state, so without a selector any mutation re-renders every island bound to that store. `useWrnStore("cart", s => s.itemCount)` caches the selected value and compares with `Object.is`, re-rendering only on actual change. ### Writes Writes go through `instance.actions.*`, never direct state assignment. The `mutableState` Proxy would technically accept a raw write, but that bypasses action naming and the `StoreMutation` record that subscribers and devtools depend on. ### The one author-facing rule Write → action → store notifies → snapshot changes → island re-renders. This terminates cleanly **provided writes never occur during render**. Writes belong in event handlers or effects. This rule is documented rather than machine-enforced — see "Dropped: the write-during-render guard" below. It is also the reason this shape was chosen over generated `bind:` sugar: the cycle stays visible in the author's own code rather than being hidden in generated glue. ## Compiler and bundler changes ### Detection `candidates()` in `packages/compiler/src/import-resolver.ts` currently resolves `.wrn`, `.ts`, `.d.ts` and index variants. Add `.tsx` and `index.tsx`, and tag `ResolvedImport` with `kind: "island"` when the resolved path ends in `.tsx`. Because authoring uses an explicit frontmatter import, detection requires no heuristics and no configuration. ### Server codegen Where a `.wrn` component import generates a server render call, an island import instead emits the placeholder marker with name, strategy, and props serialized as JSON through the existing `escapeHtml`. Since islands are client-only in v1, the server never imports React. ### Props contract Island props must be JSON-serializable. Passing a function, symbol, or class instance is a compile-time diagnostic (`WRN-ISLAND-PROPS`) rather than a runtime failure. This makes the serialization boundary explicit at the point where it is cheapest to correct. ### Bundling Each island gets a generated entry (component + mount runtime), bundled via `Bun.build`, with content-hashed output. **React must be emitted as a shared chunk.** Five islands on one page must not ship five copies of `react-dom`. This is a day-one splitting requirement, not a later optimization, because getting it wrong fails silently and multiplies bundle size. ### Route classification The compiler already classifies routes (static, static-interactive, request SSR, and so on), and that classification determines whether a route ships JavaScript. A route containing an island is no longer zero-JS static — it is static-interactive. Islands must feed into that existing classifier so the framework's performance reporting stays accurate. ### HMR On island source change: unmount the root and re-mount with the new bundle. Correct and simple; the cost is that component state resets on edit. React Fast Refresh requires a Babel/SWC transform plus a runtime and is out of scope. If authors report that state-preserving edits matter, that is the concrete evidence that would justify introducing Vite or esbuild for island bundling — and the bundler interface is the intended swap point. ## Error handling | Condition | Behavior | |---|---| | `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure | | Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact | | Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered | | Non-serializable props | Compile-time `WRN-ISLAND-PROPS` | | Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined | | Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. | | Cleanup throws on unmount | Caught and logged; navigation must not break | Islands failing **locally** is the most valuable property of this model: a crashed chart leaves the rest of the page working. ## Dropped: the write-during-render guard The design originally called for a dev-only guard that threw when an island called a store action during render. It was implemented, then removed: the mechanism is unreliable in both directions. - **False positives.** React runs effects before a queued microtask drains, so a render-phase flag cleared on a microtask is still set inside `useEffect`. An island writing from an effect — the documented correct pattern — would throw. - **False negatives.** The flag can only be set from the error boundary`s render. When an island updates its own state, only the island re-renders, so the flag is never set and a genuine write-during-render passes silently. There is no reliable public API for detecting React`s render phase; doing it properly requires React internals, which is not acceptable in a shipped framework. React already covers the real hazard: writing during render that notifies subscribers produces "Cannot update a component while rendering a different component", and the infinite-loop case is caught by the `getSnapshot` caching requirement handled in the store bridge. The custom guard added false positives without covering anything React misses. The author-facing rule still stands and is still documented — it is simply not machine-enforced. ## Testing ### Compiler unit tests - `.tsx` resolution through `candidates()` - Marker emission with correct name, strategy, and props - JSON serialization and escaping of props - `WRN-ISLAND-PROPS` diagnostic for non-serializable props - `WRN-ISLAND-REACT-MISSING` diagnostic - Route reclassification from static to static-interactive when an island is present ### Store bridge unit tests - **`getSnapshot()` returns a referentially identical value across repeated calls with no mutation, and a new one after a mutation.** This single test stands between the implementation and an infinite render loop. - Selector memoization and `Object.is` change detection - `subscribe`/`unsubscribe` symmetry ### Island runtime tests (`happy-dom`, already a dev dependency) - Mount per strategy: `only`, `load`, `visible`, `idle` - Error boundary containment - **Unmount on navigation** — roots disposed, store subscriptions released; subscription counts stay flat across repeated simulated navigations ### Integration guards Both protect the core promise: 1. A route with no islands ships **zero** framework JavaScript. 2. A page with multiple islands ships React exactly **once**. All of the above run under the existing `bun test packages` and `test:examples`, so `check:production` covers islands from day one. ## Deferred to v2 - **SSR opt-in** — `renderToString` + `hydrateRoot` for libraries that support it. The marker and bundling design already accommodate this; only the server codegen path and a hydration strategy are missing. - **`bind:` sugar** — generated two-way binding in `.wrn` markup, layered over the v1 store bridge as pure syntax. Add only if authors ask. - **React Fast Refresh** — see HMR above.