autumn-plugin-react
React islands for Autumn 0.8 apps. Put React 19 components into Maud + htmx pages. No Node at run time.
- The plugin serves a small loader through the Autumn
plugin_assetsAPI: hashed URLs,immutablecache, SRI. - Your bundler builds your components. The plugin serves that bundle the same way.
- The loader mounts islands that htmx swaps in, and unmounts islands that htmx swaps out.
- The server can send new props. React keeps the component state.
- No inline script and no
eval. The default CSP (script-src 'self') works.
Install
[]
= "0.1"
The loader needs React 19 or later. It uses the React 19 root option
onUncaughtError to show the fallback after a render crash.
Use
1. Build the components
The entry file registers your components on window.autumnReact. The
loader can run before or after this file.
// frontend/src/main.jsx
import from "react";
import from "react-dom";
import from "react-dom/client";
import from "./Counter.jsx";
let queue;
try catch
if queue = window. = ;
queue.;
flushSyncis optional. With it, the loader events fire after React commits the DOM. Without it, they fire when the render starts.versionis optional. With it, the loader refuses React before 19.
Build one classic script (no chunks):
A Vite build or esbuild --format=esm gives an ES module. Name that file
.mjs: react_bundle renders <script type="module"> for .mjs and a
classic <script defer> for .js.
frontend/
is a complete reference project. Types for the registration API are in
frontend/src/autumn-react.d.ts.
2. Install the plugin and the bundle
use ;
use PluginAssets;
use *;
static ISLANDS: PluginAssets = from_files;
async
async
react_bundlerenders a<link>for each.cssfile, a<script defer>for each.jsfile and a<script type="module">for each.mjsfile. Give the same bundle toReactPlugin::bundle, or the URLs give404.- Load the loader with
defer(asreact_scriptdoes). Island::inline()renders a<span>for an island in a<p>or a<button>.Island::attradds attributes such asroleoraria-label. It refusesid,class,data-react-*andon*.- Two different bundles with one namespace stop the app at start-up.
3. Send new props from the server
use PropsUpdate;
async
PropsUpdate writes the HX-Trigger header. htmx sends the
autumn:react:props event. The loader sets data-react-props on the
island, and React renders the same root again.
- htmx handles
HX-Triggerbefore the swap. To update an island that the same response swaps in, use.after_settle(). - The update merges with a trigger header that the response already has.
If later code replaces the header, call
PropsUpdate::apply_tolast. - Each selector updates the first matching island. Build selectors from trusted values.
- Keep header props small. Proxies limit header size.
App code can send the same event:
island.;
The island element
fallback
| Attribute | Set by | Meaning |
|---|---|---|
data-react-island |
Island::new |
The registered component name. A change mounts a new root. |
data-react-props |
Island::props |
A JSON object. A change renders the same root again. |
data-react-mount |
Island::mount_when |
idle or visible. No attribute: mount at load. |
data-react-state |
the loader | waiting, pending (name not registered yet), mounted or error. |
data-react-ignore |
you | No island in this element mounts. |
The loader sends these events. They bubble from the island. An island
that left the page sends unmount on document. detail has name and
element (and error for error):
| Event | When |
|---|---|
autumn:react:mount |
The root renders for the first time. |
autumn:react:update |
New props render. |
autumn:react:unmount |
The root unmounts. |
autumn:react:error |
Bad props, a mount error or a render crash. |
Behavior
- Mount. The loader moves the fallback out of the island, then calls
createRootwith a uniqueidentifierPrefix. There is no hydration: Rust cannot render React. - Unknown name. The island stays
pendingand shows its fallback. A later registration mounts it. - Errors. Bad props or a failed mount keep the fallback. A render crash puts the fallback back. Bad props on a mounted island keep the last good render. Each error affects one island only.
- htmx. One
MutationObservermounts added islands and unmounts removed islands. An island that moves in one task keeps its root. An unmounted island gets its fallback back. - History. After an htmx history restore, the loader removes the old React output and mounts the island again.
- Nesting. An island inside another island does not mount. React owns the outer island's DOM.
- Morphs. Do not morph the children of a mounted island (idiomorph).
Swap it with
outerHTML, or send new props.
Security
- User HTML. The loader mounts each
data-react-islandelement in the page. If your app shows user HTML, the sanitizer must removedata-react-*,hx-*anddata-hx-*attributes.data-react-ignorealone is not sufficient: an htmx out-of-band swap (hx-swap-oob) can move an element out of it. - Props. Props are in an HTML attribute. Maud escapes them. The loader
reads them with
JSON.parseand refuses a__proto__key. Do not put secrets in props. Do not spread untrusted props onto DOM elements (<div {...props}>):dangerouslySetInnerHTMLorhrefin props can then inject script. - Updates. Any same-origin script and any
HX-Triggerheader can sendautumn:react:props. Do not copy user input into a trigger header. - Names. The first registration of a name stays. A library bundle
must use a prefix in its component names (
Acme.Chart). - Component names,
id,class, extra attributes andPropsUpdateselectors are trusted input from your code.
Demo
Open http://127.0.0.1:3000. The demo runs in the dev profile. In
prod, Autumn turns on CSRF, so an htmx POST needs the CSRF token.
Development
&& &&
License
Apache-2.0. The demo bundle in examples/islands/ contains React
(MIT). It is not in the published crate.