Implemented and removed. A render-phase flag cleared on a microtask is still set when React runs effects, so islands writing from an effect — the documented correct pattern — would throw. The flag also cannot be set for an island's own re-renders, so real violations pass silently. Detecting React's render phase reliably needs React internals, which is not acceptable in a shipped framework. React already reports the real hazard, and the getSnapshot caching requirement covers the loop case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
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
.wrnauthoring 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 viacreateRootonce the bundle arrives.client:load— mount on document load.client:visible— mount viaIntersectionObserver.client:idle— mount onrequestIdleCallback.
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 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
.tsxresolution throughcandidates()- Marker emission with correct name, strategy, and props
- JSON serialization and escaping of props
WRN-ISLAND-PROPSdiagnostic for non-serializable propsWRN-ISLAND-REACT-MISSINGdiagnostic- 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.ischange detection subscribe/unsubscribesymmetry
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:
- A route with no islands ships zero framework JavaScript.
- 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+hydrateRootfor 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.wrnmarkup, layered over the v1 store bridge as pure syntax. Add only if authors ask.- React Fast Refresh — see HMR above.