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:
@@ -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.
|
||||
Reference in New Issue
Block a user