docs(react-islands): add design spec for opt-in React islands

Adds the approved design for consuming npm React components as
client-only islands inside WRNexus, without changing the SSR-first
rendering model.

Key decisions:
- New isolated @wrnexus/react package; react/react-dom as optional peers
- Client-only by default, SSR deferred to v2
- Islands declared via .tsx imports in .wrn frontmatter
- Store access via useSyncExternalStore, with the snapshot cache in the
  adapter so @wrnexus/store stays untouched
- Bundling extends the existing Bun pipeline; React as a shared chunk
- Routes with no islands must still ship zero framework JavaScript

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:01:58 +05:30
co-authored by Claude Opus 5
parent d78707be9f
commit 4ef2ef14ff
@@ -0,0 +1,241 @@
# 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 `<Chart client:only />` |
| 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/<hash>.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 enforced in dev (see Error handling), not merely documented. 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 | Dev: throw with a targeted message pointing at the handler/effect rule (React's own warning is too generic to diagnose quickly) |
| 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.
## 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.