diff --git a/docs/superpowers/specs/2026-08-18-react-islands-design.md b/docs/superpowers/specs/2026-08-18-react-islands-design.md new file mode 100644 index 00000000..8d2777ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-react-islands-design.md @@ -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 `` | +| 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/.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.