Files
WRNexusJS/docs/superpowers/specs/2026-08-18-react-islands-design.md
T
ClintchizandClaude Opus 5 52cce2c628 style(docs): apply Prettier to the React islands spec and plan
Formatting only; no content change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:45 +05:30

13 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 .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 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-inrenderToString + 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.