Formatting only; no content change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268 lines
13 KiB
Markdown
268 lines
13 KiB
Markdown
# 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-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.
|