Foreign-UI interop (React)#
A mature front-end leans on a third-party ecosystem — node-graph editors, animation libraries, data grids, PDF viewers — that doesn't mount on a foreign runtime. janux/interop lets you mount them unchanged, each in a real embedded React root, while everything around them stays bifacial.
import { foreign } from 'janux/interop';
import { ReactFlow } from '@xyflow/react'; // unchanged, third-party
const Flow = foreign(ReactFlow, {
props: (own) => ({ nodes: own.state.nodes }), // Janux state → React props (tracked)
on: { onNodeDrag: 'moveNode' }, // React callback → island intent
hydrate: 'visible', // load | idle | visible | only
});Two component kinds, one tree#
- Bifacial island (
component({...})) — projects view + resource + intents. Preferred for all new code and anything the agent should drive. - Foreign island (
foreign(ReactComponent, options)) — an existing React component mounted inside a Janux view. It projects a view only: it is opaque to agents by default.
The boundary contract#
- One real root per island.
react-domcreateRootper foreign leaf — concurrent scheduling, portals, refs and the synthetic-event contract behave exactly as the library authors assume. The root unmounts when its host leaves the view (and cascades when the enclosing island disposes). - Reactive props bridge.
options.props(own)receives the JSX call-site props (typically the enclosing island'sstate) and is tracked: signal reads re-render only the foreign root — no Janux DOM diffing crosses the boundary. The host element is an opaque leaf for the morph. - Events → intents.
options.onmaps a foreign callback prop to an intent NAME on the enclosing island. A drag in a graph editor lands as a typed intent — auditable, guardable, and invocable by the copilot without dragging anything. The callback's first argument becomes the intent input. - SSR + client directives. When
react/react-domare installed, the component server-renders inside its host (paint before JS); the client root mounts perhydrate:load(default),idle,visible(IntersectionObserver) oronly(skip SSR). The client performs a deterministic render-replace over the SSR markup — interactions can land before a lazy hydration would finish, so replace beats mismatch races.
Writing React components inside a Janux app: give the file its own runtime with a pragma, everything else is plain React:
/** @jsxImportSource react */
export function FlowCanvas({ nodes, onMove }: Props) { /* plain React */ }Making a foreign island agent-legible (wrap once)#
An opaque island is invisible to agents. Wrap it in a bifacial shell whose state it renders and whose intents drive it — the copilot then has full power over it without DOM inference:
export const WorkflowEditor = component({
name: 'workflow-editor',
state: schema({ nodes: list(nodeSchema) }),
intents: {
addNode: intent({ input: nodeSchema, run: ({ state, input }) => state.nodes.push(input) }),
moveNode: intent({ input: moveSchema, run: ({ state, input }) => { /* … */ } }),
clear: intent({ guard: 'confirm', run: ({ state }) => (state.nodes = []) }),
},
view: ({ state }) => <Flow state={state} />, // the React editor renders the state
});Human gestures update state through intents (via on:); the agent invokes the same intents — every DOM-scraped action becomes a typed tool.
Two JSX runtimes in one project, step by step#
Janux compiles .tsx with its own JSX runtime by default, while your React components need React's. Both coexist per file — the pragma decides:
- Install React in your app (Janux doesn't bundle it):
bun add react react-dom(+bun add -d @types/react @types/react-dom). - Janux files need nothing: any
.tsxwithout a pragma uses the Janux runtime (components, pages, layouts). - React files start with the pragma so TypeScript and the compiler use React's runtime for that file only:
/** @jsxImportSource react */
import { useState } from 'react';
export function Gauge({ level }: { level: number }) {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen(!open)}>{level}{open ? '!' : ''}</button>;
}- Never mix runtimes in one file. A file is either Janux JSX (
class,onClick={intents.x}) or React JSX (className,onClick). Cross the boundary only withforeign(). - Mount it from a Janux view via
foreign()(see above) — Janux SSRs it, hydrates a real React root, and bridges props/events. - One React, resolved from your app. The Vite plugin resolves
react/react-domfrom your project root and dedupes them, so a linked or nested dependency can never introduce a second React (the classic "invalid hook call" trap). If you see hook errors, checkbun pm ls reactfor duplicates.
That's the whole model: Janux owns the tree, React owns its leaves, and each file declares which language it speaks.
Notes & current limits#
- Foreign components never appear in the manifest — by design. Agent capability comes from the wrapping shell.
- A standalone foreign (outside any island) SSRs and mounts from its serialized call-site props;
on:requires an enclosing island. react/react-domare optional peers — apps without foreign islands pay nothing.- Reverse interop (mounting a Janux island inside a React app) is on the roadmap.
See it running:
examples/interop-react— A React component mounted unchanged, with tracked props and callbacks bridged to intents.examples/with-web-agentgoes one step further with@xyflow/react(hydrate: 'only', since React Flow measures the viewport on mount) and an agent adding nodes to it. More in Examples.