1. Product thesis
The core mistake to avoid is building a fake universal component framework. React and Compose should keep doing what they are excellent at: React reconciles JavaScript component trees and hook state; Compose composes Kotlin UI with snapshot state and platform-aware rendering. Reaktor sits below both as a retained, typed, inspectable host runtime.
Hard invariants
No private internals
Use React at the custom renderer boundary and Compose at the Applier/ComposeNode boundary. Do not depend on private Fiber fields or the Compose SlotTable layout.
One structural owner per subtree
React, Compose, server UI, and Blueprint each own explicit runtime boundaries. Parents may mount or pass props into child boundaries, not mutate their internals directly.
Batch then cross runtime
Hermes/native RPC receives frame-sized mutation batches, not one call per node operation or prop update.
Native tree is canonical
HostTree is source of truth. ShadowTree is an incremental derived view for layout metadata, event routing, accessibility, rendering, profiling, and tools.
2. Killer apps and natural extensions
A. React control plane over Compose components
This is the most commercially direct use case. Business logic, layout composition, A/B variants, campaign cards, feature flags, eligibility rules, and personalization ship as signed React/Hermes bundles. The rendered primitives are still native Compose primitives, so the app keeps native platform behavior, accessibility, input, and rendering.
B. Compose driving React for web
Compose Multiplatform can remain the authoring model for shared screens, but the web renderer can project the HostTree into React DOM. That gives the web target mature layout, hydration, browser APIs, and the existing React ecosystem for charts, maps, rich text, video, editors, analytics wrappers, and design-system infrastructure.
C. What falls out naturally
Gradual migration
React Native or React-heavy teams can add Compose screens without a big-bang rewrite. Compose shell and React islands share a tree, events, state cells, and navigation boundaries.
Platform-expert rendering
Author primitives once: RText, RBox, RButton. Render with Compose on native, React DOM on web, and custom targets for embedded/TV.
Server-driven UI that is not JSON garbage
Server chooses signed React bundles or TreeOps with real control flow, state, error handling, animation hooks, and typed capabilities.
AI-agent-driven UI
Agents can inspect HostTree/ShadowTree snapshots and emit typed mutations instead of generating raw platform source code.
Blueprint design tool
A visual editor manipulates the same tree as React and Compose. Selection maps to NodeId, bounds, props schema, and source boundary.
Composable deployment
Individual runtime islands can be released, rolled back, or disabled independently while preserving native shell state.
Alternative comparison
| Approach | Primary problem | Reaktor position |
|---|---|---|
| JSON SDUI | No real language, weak state/error handling, one-off DSL per company. | React bundles or typed TreeOps provide real logic over native rendering. |
| React Native alone | Does not give native Compose ownership and inherits RN platform architecture choices. | React is the dynamic authoring layer; Compose remains the native renderer. |
| Compose Multiplatform alone | Web target and ecosystem reuse remain weaker than React DOM for many products. | Compose model can render to React DOM on web. |
| Flutter | Separate ecosystem and renderer; cannot reuse React or Compose authoring directly. | Renderer swap via HostTree, not wholesale rewrite. |
| Reaktor | Requires kernel engineering and careful lifecycle boundaries. | Native rendering + OTA control plane + web ecosystem bridge. |
3. Conceptual architecture
Runtime layers
| Layer | Responsibility | Primary artifacts |
|---|---|---|
| Authoring frontends | Idiomatic React, Compose, server, AI, Blueprint entry points. | React components, composables, bundle manifests, graph specs. |
| Runtime adapters | Translate reconciler-specific operations to Reaktor TreeOps. | React HostConfig, Compose Applier, server compiler. |
| reaktor-ffi | Typed RPC/ABI over Hermes/JSI with low-copy ArrayBuffer batches. | HostRpc, event batch callback, state cell RPC. |
| Mutation engine | Validate, compact, order, and apply TreeOps. | Batch reader/writer, op coalescer, invariant checker. |
| HostTree | Canonical retained tree with stable identity and ownership. | NodeArena, HostNode, ChildBuffer, PropsRef, EventRef. |
| Shadow pipeline | Incrementally derive renderer-facing snapshots. | ShadowNode, dirty propagation, layout cache, hit-test index. |
| Renderers | Project snapshots to platform UI. | Compose renderer, React DOM renderer, headless renderer. |
| Interop services | Make mixed runtime screens seamless. | StateCell, event router, animation clock, focus/accessibility, lifecycle. |
| Tooling | Make runtime complexity visible. | Inspector protocol, Perfetto traces, Blueprint bridge, AI mutation API. |
Ownership island model
Boundaries are not only logical markers. They own scheduling, event sinks, resource scopes, error handling, lifecycle cleanup, and hot-reload replacement. A boundary can be mounted by a parent but is internally mutated only by its owner runtime.
4. Actual libraries and module map
JavaScript/React packages
{
"name": "@reaktor/react",
"version": "0.1.0",
"private": false,
"peerDependencies": {
"react": "^19.0.0"
},
"dependencies": {
"react-reconciler": "0.34.x",
"scheduler": "^0.27.0",
"zod": "^4.0.0"
},
"devDependencies": {
"typescript": "^5.8.0",
"@types/react": "^19.0.0",
"vite": "^6.0.0"
}
}
Kotlin/Compose setup
plugins {
kotlin("multiplatform") version "2.1.20"
id("com.android.library") version "8.8.0"
id("org.jetbrains.compose") version "1.8.0"
kotlin("plugin.serialization") version "2.1.20"
}
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm("desktop")
wasmJs { browser() }
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1")
}
androidMain.dependencies {
implementation(compose.ui)
implementation("androidx.tracing:tracing-ktx:1.3.0")
}
}
}
Reaktor modules
| Module | Responsibility | Public? |
|---|---|---|
reaktor-tree-core | NodeId, HostType, TreeOp, TreeCommit, BoundaryId, DirtyFlags. | Mostly stable |
reaktor-tree-storage | Arena, child buffers, props tables, batch pools, invariant checks. | Internal |
reaktor-ffi | Hermes/JSI RPC install, ArrayBuffer transport, native event callbacks. | Internal ABI |
@reaktor/react | React primitives, hooks, custom renderer, bundle registration. | Public |
reaktor-compose-authoring | Compose primitives, Applier, ReactIsland/BoundaryHostNode. | Public |
reaktor-compose-renderer | ShadowSnapshot to Compose UI projection. | Public-ish |
@reaktor/react-dom-renderer | Shadow/Host snapshots to React DOM projection. | Public web |
reaktor-runtime-interop | StateCell, event routing, focus/accessibility, animation clock. | Stable contracts |
reaktor-devtools | Inspector protocol, visual tree, traces, hot reload. | Public tooling |
5. Public authoring APIs
Public APIs should feel boring. React developers write React. Compose developers write composables. Reaktor primitives hide the HostTree, FFI, event refs, and binary batch format.
React island authoring
import * as React from "react";
import {
RBox,
RText,
RButton,
RImage,
ReactBoundaryProps,
useBoundaryProps,
useReaktorState,
registerReaktorComponent,
} from "@reaktor/react";
type CampaignCardProps = ReactBoundaryProps<{
campaignId: string;
title: string;
subtitle: string;
imageUrl?: string;
cta: string;
accentToken: string;
initialExpanded?: boolean;
}>;
function CampaignCard() {
const props = useBoundaryProps<CampaignCardProps>();
const [expanded, setExpanded] = React.useState(!!props.initialExpanded);
// Shared state cell can be read by Compose and React in the same frame.
const [cart, setCart] = useReaktorState<{ count: number }>("cart.summary");
return (
<RBox
testId="campaign-card"
role="button"
style={{
direction: "column",
padding: 16,
radius: 20,
gap: 10,
background: { token: "surface.elevated" },
borderColor: { token: props.accentToken },
}}
onPress={() => setExpanded((x) => !x)}
>
{props.imageUrl ? (
<RImage source={{ uri: props.imageUrl }} style={{ height: 144, radius: 16 }} />
) : null}
<RText variant="titleMedium" text={props.title} />
<RText variant="body" color={{ token: "text.secondary" }} text={props.subtitle} />
{expanded ? (
<RBox style={{ direction: "row", gap: 8, align: "center" }}>
<RButton
label={props.cta}
onPress={() => setCart({ count: cart.count + 1 })}
/>
<RText variant="caption" text={`Cart: ${cart.count}`} />
</RBox>
) : null}
</RBox>
);
}
registerReaktorComponent("campaign-card@1", CampaignCard);
Compose shell embedding a React island
@Composable
fun CheckoutScreen(orderId: String, campaignId: String) {
val cart by rememberReaktorState<CartSummary>("cart.summary")
ReaktorSurface(
surfaceKey = "checkout:$orderId",
renderer = ReaktorRenderer.ComposeNative,
) {
RColumn(
modifier = RModifier
.fillMaxWidth()
.padding(16.dp)
.gap(12.dp)
) {
RText("Checkout", variant = RTextVariant.Headline)
RText("Items in cart: ${cart.count}", variant = RTextVariant.Body)
// React controls the subtree; Compose owns the parent shell.
ReactIsland(
module = "campaign-card@1",
key = "campaign:$campaignId",
props = CampaignCardProps(
campaignId = campaignId,
title = "Members save 20% today",
subtitle = "A/B tested copy can ship as a JS bundle.",
cta = "Apply offer",
accentToken = "brand.purple",
),
fallback = {
RBox(RModifier.height(96.dp).skeleton())
}
)
RButton("Pay now") {
// Native Compose business flow continues normally.
}
}
}
}
Compose model rendered through React DOM on web
// shared-ui/src/main/kotlin/MarketingPage.kt
@Composable
fun MarketingPage(plan: PricingPlan) {
RColumn(RModifier.maxWidth(960.dp).gap(24.dp)) {
RText("Choose a plan", variant = RTextVariant.Display)
RRow(RModifier.gap(16.dp)) {
plan.tiers.forEach { tier ->
PricingCard(tier)
}
}
// Web-only capability: project a typed host node into a React library.
ReactDomLibrarySlot(
component = "recharts:RevenueChart",
props = mapOf("series" to plan.chartSeries)
)
}
}
// web/src/main.ts
import { createRoot } from "react-dom/client";
import { createReaktorWebSurface } from "@reaktor/web";
import { ReactDomRenderer } from "@reaktor/react-dom-renderer";
const root = createRoot(document.getElementById("root")!);
const surface = createReaktorWebSurface({ renderer: new ReactDomRenderer(root) });
// Kotlin/JS or Kotlin/Wasm Compose authoring emits HostTree mutations.
window.ReaktorWebBoot.mountMarketingPage(surface.id, window.__BOOTSTRAP_PLAN__);
6. reaktor-ffi RPC shape over Hermes
commitBatch(meta, ArrayBuffer); all events and state changes are batched or versioned.
TypeScript ABI exposed to React/Hermes
// @reaktor/ffi: installed into Hermes as globalThis.__ReaktorHost.
// Values use numbers for ids because Hermes/JSI can pass them cheaply.
export type SurfaceId = number & { readonly __surface: unique symbol };
export type BoundaryId = number & { readonly __boundary: unique symbol };
export type NodeId = number & { readonly __node: unique symbol };
export type Revision = number & { readonly __revision: unique symbol };
export type EventRef = number & { readonly __event: unique symbol };
export type StateCellId = number & { readonly __stateCell: unique symbol };
export type ReaktorPriority =
| "ImmediateInput"
| "Animation"
| "VisibleUi"
| "BackgroundUi"
| "Data"
| "Idle";
export interface BatchMeta {
surfaceId: SurfaceId;
boundaryId: BoundaryId;
baseRevision: Revision;
priority: ReaktorPriority;
opCount: number;
stringTableSize: number;
propsTableSize: number;
traceId?: string;
}
export interface CommitResult {
accepted: boolean;
revision: Revision;
rejectedReason?: string;
serverTimeNanos: number;
}
export interface ReaktorHostRpc {
createSurface(options: {
renderer: "compose-native" | "react-dom" | "headless";
rootKey: string;
capabilities: string[];
}): SurfaceId;
createBoundary(options: {
surfaceId: SurfaceId;
parentNodeId: NodeId | 0;
owner: "react" | "compose" | "server" | "blueprint";
key: string;
module?: string;
}): BoundaryId;
destroyBoundary(boundaryId: BoundaryId, reason: "unmount" | "hot-reload" | "error"): void;
commitBatch(meta: BatchMeta, encodedOps: ArrayBuffer): CommitResult;
registerEventHandler(boundaryId: BoundaryId, localHandlerId: number): EventRef;
unregisterEventHandler(eventRef: EventRef): void;
// Native -> JS delivery is batched. This avoids one callback per pointer movement.
setEventBatchCallback(boundaryId: BoundaryId, callback: (batch: ArrayBuffer) => void): void;
createStateCell(schemaId: string, initialValue: ArrayBuffer): StateCellId;
readStateCell(cellId: StateCellId): ArrayBuffer;
writeStateCell(cellId: StateCellId, expectedVersion: number, value: ArrayBuffer): number;
subscribeStateCell(cellId: StateCellId, callback: (version: number) => void): () => void;
getSnapshot(surfaceId: SurfaceId, kind: "host" | "shadow", revision?: Revision): ArrayBuffer;
reportBoundaryError(boundaryId: BoundaryId, encodedError: ArrayBuffer): void;
}
declare global {
// Installed by reaktor-ffi when the Hermes runtime starts.
// It is intentionally not a generic bridge; it is the UI kernel ABI.
var __ReaktorHost: ReaktorHostRpc;
}
Kotlin endpoint implemented by native runtime
interface ReaktorFfiEndpoint {
fun createSurface(options: SurfaceOptions): SurfaceId
fun createBoundary(options: BoundaryOptions): BoundaryId
fun destroyBoundary(boundaryId: BoundaryId, reason: DestroyReason)
fun commitBatch(meta: BatchMeta, encodedOps: ByteBuffer): CommitResult
fun registerEventHandler(boundaryId: BoundaryId, localHandlerId: Int): EventRef
fun unregisterEventHandler(eventRef: EventRef)
fun setEventBatchSink(boundaryId: BoundaryId, sink: EventBatchSink?)
fun createStateCell(schemaId: String, initialValue: ByteBuffer): StateCellId
fun readStateCell(cellId: StateCellId): ByteBuffer
fun writeStateCell(cellId: StateCellId, expectedVersion: Int, value: ByteBuffer): Int
fun subscribeStateCell(cellId: StateCellId, observer: StateObserver): Subscription
fun snapshot(surfaceId: SurfaceId, kind: SnapshotKind, revision: Long?): ByteBuffer
fun reportBoundaryError(boundaryId: BoundaryId, error: BoundaryError)
}
data class BatchMeta(
val surfaceId: SurfaceId,
val boundaryId: BoundaryId,
val baseRevision: Long,
val priority: ReaktorPriority,
val opCount: Int,
val stringTableSize: Int,
val propsTableSize: Int,
val traceId: String? = null,
)
C++/JSI installation sketch
// reaktor-ffi/hermes/ReaktorHostObject.cpp
class ReaktorHostObject final : public facebook::jsi::HostObject {
public:
explicit ReaktorHostObject(std::shared_ptr<ReaktorRuntime> runtime)
: runtime_(std::move(runtime)) {}
facebook::jsi::Value get(
facebook::jsi::Runtime& rt,
const facebook::jsi::PropNameID& name) override {
auto method = name.utf8(rt);
if (method == "commitBatch") {
return facebook::jsi::Function::createFromHostFunction(
rt,
name,
2,
[this](facebook::jsi::Runtime& rt,
const facebook::jsi::Value&,
const facebook::jsi::Value* args,
size_t count) -> facebook::jsi::Value {
auto meta = decodeBatchMeta(rt, args[0].asObject(rt));
auto bytes = arrayBufferView(rt, args[1].asObject(rt));
CommitResult result = runtime_->commitBatch(meta, bytes);
return encodeCommitResult(rt, result);
}
);
}
if (method == "registerEventHandler") {
return makeRegisterEventHandler(rt, name);
}
if (method == "readStateCell") {
return makeReadStateCell(rt, name);
}
return facebook::jsi::Value::undefined();
}
private:
std::shared_ptr<ReaktorRuntime> runtime_;
};
void installReaktorFfi(
facebook::jsi::Runtime& rt,
std::shared_ptr<ReaktorRuntime> runtime) {
auto host = std::make_shared<ReaktorHostObject>(std::move(runtime));
auto object = facebook::jsi::Object::createFromHostObject(rt, host);
rt.global().setProperty(rt, "__ReaktorHost", std::move(object));
}
Why this shape
- ArrayBuffer mutation batches reduce per-op serialization and native call overhead.
- Opaque ids keep callbacks, state cells, resources, and nodes out of ad-hoc JS object graphs.
- Event batch callbacks let native deliver pointer/focus/keyboard events in groups by boundary.
- StateCell versioning gives React and Compose ordering guarantees without forcing one runtime scheduler.
- Snapshot RPC powers DevTools, Blueprint, AI inspection, and deterministic tests.
7. Mutation protocol and batch format
Binary batch v0
// Batch v0: little-endian, 8-byte aligned where possible.
// Header is fixed. Variable tables follow.
struct BatchHeaderV0 {
uint32 magic; // 'RKTB'
uint16 version; // 0
uint16 flags; // compression, checksum, debug-symbols
uint32 opCount;
uint32 stringBytes;
uint32 propsBytes;
uint32 eventBytes;
uint64 baseRevision;
}
enum OpCode : uint8 {
CreateNode = 1,
DeleteNode = 2,
InsertChild = 3,
MoveChild = 4,
RemoveChild = 5,
UpdateProps = 6,
UpdateEventRef = 7,
MarkBoundary = 8,
BoundaryError = 9,
}
// Hot op records are fixed width to keep native decoding branch-light.
struct InsertChildOp {
uint8 opcode;
uint8 reserved[7];
uint64 parentNodeId;
uint64 childNodeId;
uint32 index;
uint32 ownerBoundaryId;
}
struct UpdatePropsOp {
uint8 opcode;
uint8 patchKind; // full, sparse, generated-schema-patch
uint16 schemaId;
uint32 patchOffset; // into props table
uint32 patchLength;
uint64 nodeId;
}
TypeScript batch writer
export const enum OpCode {
CreateNode = 1,
DeleteNode = 2,
InsertChild = 3,
MoveChild = 4,
RemoveChild = 5,
UpdateProps = 6,
UpdateEventRef = 7,
MarkBoundary = 8,
BoundaryError = 9,
}
export class MutationBatchWriter {
private buf = new ArrayBuffer(64 * 1024);
private view = new DataView(this.buf);
private offset = 32; // header space
private opCount = 0;
constructor(
private readonly surfaceId: SurfaceId,
private readonly boundaryId: BoundaryId,
private readonly baseRevision: Revision,
) {}
createNode(id: NodeId, typeId: number, owner: BoundaryId) {
this.ensure(32);
this.view.setUint8(this.offset, OpCode.CreateNode);
this.view.setUint32(this.offset + 4, typeId, true);
this.setU64(this.offset + 8, id as number);
this.setU64(this.offset + 16, owner as number);
this.offset += 32;
this.opCount++;
}
insertChild(parent: NodeId, child: NodeId, index: number) {
this.ensure(32);
this.view.setUint8(this.offset, OpCode.InsertChild);
this.setU64(this.offset + 8, parent as number);
this.setU64(this.offset + 16, child as number);
this.view.setUint32(this.offset + 24, index, true);
this.offset += 32;
this.opCount++;
}
updateProps(node: NodeId, schemaId: number, encodedPatch: Uint8Array) {
const alignedPatchOffset = this.writeBlob(encodedPatch);
this.ensure(32);
this.view.setUint8(this.offset, OpCode.UpdateProps);
this.view.setUint16(this.offset + 2, schemaId, true);
this.view.setUint32(this.offset + 4, alignedPatchOffset, true);
this.view.setUint32(this.offset + 8, encodedPatch.byteLength, true);
this.setU64(this.offset + 16, node as number);
this.offset += 32;
this.opCount++;
}
finish(priority: ReaktorPriority): { meta: BatchMeta; bytes: ArrayBuffer } {
this.view.setUint32(0, 0x42544b52, true); // RKTB
this.view.setUint16(4, 0, true);
this.view.setUint32(8, this.opCount, true);
this.setU64(24, this.baseRevision as number);
return {
meta: {
surfaceId: this.surfaceId,
boundaryId: this.boundaryId,
baseRevision: this.baseRevision,
priority,
opCount: this.opCount,
stringTableSize: 0,
propsTableSize: 0,
},
bytes: this.buf.slice(0, this.offset),
};
}
reset(nextRevision: Revision) {
this.offset = 32;
this.opCount = 0;
// Caller updates baseRevision through a new writer instance or a mutable field.
}
private writeBlob(bytes: Uint8Array): number {
const start = this.align(this.offset, 8);
this.ensure(start - this.offset + bytes.byteLength);
new Uint8Array(this.buf, start, bytes.byteLength).set(bytes);
this.offset = this.align(start + bytes.byteLength, 8);
return start;
}
private ensure(bytes: number) {
if (this.offset + bytes <= this.buf.byteLength) return;
const bigger = new ArrayBuffer(this.buf.byteLength * 2 + bytes);
new Uint8Array(bigger).set(new Uint8Array(this.buf));
this.buf = bigger;
this.view = new DataView(this.buf);
}
private align(n: number, boundary: number) {
return (n + boundary - 1) & ~(boundary - 1);
}
private setU64(offset: number, value: number) {
this.view.setBigUint64(offset, BigInt(value), true);
}
}
Commit application inside the native tree store
class TreeStore(
private val arena: NodeArena,
private val boundaries: BoundaryRegistry,
) {
fun apply(batch: DecodedBatch): TreeCommit {
val dirty = LongArraySet()
val layoutRoots = LongArraySet()
for (op in batch.ops) {
when (op) {
is TreeOp.CreateNode -> {
val node = arena.allocate(op.type, op.owner)
check(node.id == op.id) { "JS/native NodeId allocation drift" }
dirty.add(node.id.raw)
}
is TreeOp.InsertChild -> {
val parent = arena.get(op.parent)
val child = arena.get(op.child)
check(parent.owner.canAdopt(child.owner, boundaries))
parent.children.insert(op.index, child.id)
child.parent = parent.id
markDirty(parent, DirtyFlags.Structure or DirtyFlags.Layout, dirty, layoutRoots)
}
is TreeOp.UpdateProps -> {
val node = arena.get(op.id)
val changed = propsStore.applyPatch(node.props, op.patch)
node.dirty = node.dirty or changed.toDirtyFlags()
markDirty(node, node.dirty, dirty, layoutRoots)
}
is TreeOp.RemoveChild -> removeChild(op, dirty, layoutRoots)
is TreeOp.MoveChild -> moveChild(op, dirty, layoutRoots)
is TreeOp.DeleteNode -> arena.freeSubtree(op.id) { node ->
eventRegistry.releaseForNode(node.id)
propsStore.release(node.props)
}
}
}
return TreeCommit(
revision = revisions.next(),
dirtyNodeIds = dirty.toNodeIds(),
layoutRoots = layoutRoots.toNodeIds(),
sourceBoundary = batch.meta.boundaryId,
)
}
}
Operation compaction rules
| Pattern | Compaction | Safety rule |
|---|---|---|
| Repeated prop updates on same node/key in one frame | Keep only final value. | Do not collapse event registration side effects until old refs are released. |
| Remove + insert same node under same parent | Convert to MoveChild. | Only when ownership boundary is unchanged. |
| Create + delete before commit | Drop entire subtree. | Release event refs and props allocations. |
| Large adjacent inserts | Use range insert record. | Require contiguous child ids or side table. |
8. React custom renderer internals
The React adapter is a custom renderer. The exact HostConfig signature is version-pinned to the chosen react-reconciler release, but the design does not depend on private Fiber fields. It only consumes host lifecycle callbacks and flushes Reaktor batches at commit boundaries.
import Reconciler from "react-reconciler";
import { DefaultEventPriority } from "react-reconciler/constants";
import { MutationBatchWriter } from "./MutationBatchWriter";
import { encodePropsPatch, diffProps } from "./props";
import { events } from "./events";
type Type = "RBox" | "RText" | "RButton" | "RImage" | "RTextInput";
type Instance = {
nodeId: NodeId;
type: Type;
boundaryId: BoundaryId;
childCount: number;
};
type TextInstance = Instance & { type: "RText" };
type RootContainer = {
surfaceId: SurfaceId;
boundaryId: BoundaryId;
rootNodeId: NodeId;
revision: Revision;
writer: MutationBatchWriter;
priority: ReaktorPriority;
};
function allocNode(container: RootContainer, type: Type): Instance {
const id = ids.nextNodeId();
container.writer.createNode(id, hostTypeRegistry.idFor(type), container.boundaryId);
return { nodeId: id, type, boundaryId: container.boundaryId, childCount: 0 };
}
const hostConfig = {
supportsMutation: true,
isPrimaryRenderer: false,
supportsPersistence: false,
supportsHydration: false,
now: Date.now,
getCurrentEventPriority: () => DefaultEventPriority,
getPublicInstance: (instance: Instance) => instance,
getRootHostContext: () => null,
getChildHostContext: () => null,
shouldSetTextContent: () => false,
prepareForCommit: () => null,
resetAfterCommit(container: RootContainer) {
const { meta, bytes } = container.writer.finish(container.priority);
const result = globalThis.__ReaktorHost.commitBatch(meta, bytes);
if (!result.accepted) throw new Error(result.rejectedReason ?? "Reaktor commit rejected");
container.revision = result.revision;
container.writer = new MutationBatchWriter(
container.surfaceId,
container.boundaryId,
result.revision,
);
},
createInstance(type: Type, props: any, root: RootContainer): Instance {
const instance = allocNode(root, type);
const patch = encodePropsPatch(type, {}, props, events.forBoundary(root.boundaryId));
root.writer.updateProps(instance.nodeId, schemaRegistry.idFor(type), patch);
return instance;
},
createTextInstance(text: string, root: RootContainer): TextInstance {
const instance = allocNode(root, "RText") as TextInstance;
const patch = encodePropsPatch("RText", {}, { text }, events.forBoundary(root.boundaryId));
root.writer.updateProps(instance.nodeId, schemaRegistry.idFor("RText"), patch);
return instance;
},
appendInitialChild(parent: Instance, child: Instance) {
// React calls this while the child is still local to the in-progress tree.
currentRoot().writer.insertChild(parent.nodeId, child.nodeId, parent.childCount++);
},
appendChild(parent: Instance, child: Instance) {
currentRoot().writer.insertChild(parent.nodeId, child.nodeId, parent.childCount++);
},
appendChildToContainer(container: RootContainer, child: Instance) {
container.writer.insertChild(container.rootNodeId, child.nodeId, 0);
},
insertBefore(parent: Instance, child: Instance, beforeChild: Instance) {
const index = hostChildren.indexOf(parent.nodeId, beforeChild.nodeId);
currentRoot().writer.insertChild(parent.nodeId, child.nodeId, index);
},
removeChild(parent: Instance, child: Instance) {
const index = hostChildren.indexOf(parent.nodeId, child.nodeId);
currentRoot().writer.removeChild(parent.nodeId, index, 1);
currentRoot().writer.deleteNode(child.nodeId);
},
removeChildFromContainer(container: RootContainer, child: Instance) {
container.writer.removeChild(container.rootNodeId, 0, 1);
container.writer.deleteNode(child.nodeId);
},
prepareUpdate(instance: Instance, type: Type, oldProps: any, newProps: any) {
// Generated schema diff short-circuits identity-equal nested style objects.
return diffProps(type, oldProps, newProps);
},
commitUpdate(instance: Instance, updatePayload: any, type: Type) {
if (!updatePayload || updatePayload.isEmpty) return;
const patch = encodePropsPatch(type, updatePayload.oldProps, updatePayload.newProps, events);
currentRoot().writer.updateProps(instance.nodeId, schemaRegistry.idFor(type), patch);
},
commitTextUpdate(textInstance: TextInstance, oldText: string, newText: string) {
if (oldText === newText) return;
const patch = encodePropsPatch("RText", { text: oldText }, { text: newText }, events);
currentRoot().writer.updateProps(textInstance.nodeId, schemaRegistry.idFor("RText"), patch);
},
clearContainer(container: RootContainer) {
container.writer.clearBoundary(container.boundaryId);
return false;
},
};
export const ReaktorReactReconciler = Reconciler(hostConfig as any);
export function renderReactBoundary(
element: React.ReactNode,
container: RootContainer,
) {
const root = ReaktorReactReconciler.createContainer(
container,
0, // LegacyRoot/ConcurrentRoot depends on pinned reconciler build.
null,
false,
null,
"",
console.error,
null,
);
ReaktorReactReconciler.updateContainer(element, root, null, null);
return root;
}
Generated prop diffing
// Generated per HostType; generic object diff is fallback only.
export function diffRBoxProps(oldProps: RBoxProps, nextProps: RBoxProps): RBoxPatch | EmptyPatch {
if (oldProps === nextProps) return EMPTY_PATCH;
let mask = 0;
const out: Partial<RBoxProps> = {};
if (oldProps.testId !== nextProps.testId) {
mask |= RBoxMask.TestId;
out.testId = nextProps.testId;
}
// Style objects are expected to be immutable and structurally shared.
if (oldProps.style !== nextProps.style) {
mask |= RBoxMask.Style;
out.style = diffStyle(oldProps.style, nextProps.style);
}
if (oldProps.role !== nextProps.role) {
mask |= RBoxMask.Role;
out.role = nextProps.role;
}
// Functions are not serialized. They become small EventRef ids.
if (oldProps.onPress !== nextProps.onPress) {
mask |= RBoxMask.OnPress;
out.onPress = eventRegistry.refFor(nextProps.onPress);
}
return mask === 0 ? EMPTY_PATCH : { schemaId: Schema.RBox, mask, out };
}
React renderer responsibilities
- Allocate NodeIds from a boundary-scoped id allocator or request a native id range.
- Encode props with generated schema-aware diff functions.
- Convert callbacks to EventRef ids stored in a JS event registry.
- Flush one batch in
resetAfterCommit, not during every host call. - Unmount boundaries explicitly so React effects clean up before arena nodes are recycled.
9. Compose Applier internals
Compose authoring uses public Compose Runtime concepts: ComposeNode creates Reaktor nodes and ReaktorApplier receives insert/remove/move/clear callbacks. The Applier opens a Reaktor transaction in onBeginChanges and commits in onEndChanges.
class ReaktorNode internal constructor(
val nodeId: NodeId,
val type: HostType,
val boundaryId: BoundaryId,
private val buffer: NativeMutationBuffer,
) {
fun setProp(key: PropKey, value: Any?) {
buffer.updateProp(nodeId, type.schemaId, key, value)
}
fun setEvent(key: EventKey, handler: EventHandler?) {
val ref = handler?.let { buffer.events.register(boundaryId, it) }
buffer.updateEventRef(nodeId, key, ref)
}
}
class ReaktorApplier(
root: ReaktorNode,
private val transaction: ComposeTransactionScope,
) : AbstractApplier<ReaktorNode>(root) {
override fun onBeginChanges() {
transaction.begin(priority = ReaktorPriority.VisibleUi)
}
override fun insertTopDown(index: Int, instance: ReaktorNode) {
transaction.buffer.insertChild(
parent = current.nodeId,
child = instance.nodeId,
index = index,
)
}
// HostTree is cheaper top-down; children will be attached as Compose descends.
override fun insertBottomUp(index: Int, instance: ReaktorNode) = Unit
override fun remove(index: Int, count: Int) {
transaction.buffer.removeChild(current.nodeId, index, count)
}
override fun move(from: Int, to: Int, count: Int) {
transaction.buffer.moveChild(current.nodeId, from, to, count)
}
override fun onClear() {
transaction.buffer.clearBoundary(current.boundaryId)
}
override fun onEndChanges() {
transaction.commit()
}
}
@Composable
fun RText(
text: String,
modifier: RModifier = RModifier,
variant: RTextVariant = RTextVariant.Body,
color: RColorRef = RColorRef.Token("text.primary"),
) {
val runtime = LocalReaktorRuntime.current
ComposeNode<ReaktorNode, ReaktorApplier>(
factory = { runtime.createComposeNode(HostType.Text) },
update = {
set(text) { setProp(PropKey.Text, it) }
set(modifier) { setProp(PropKey.Modifier, it) }
set(variant) { setProp(PropKey.TextVariant, it) }
set(color) { setProp(PropKey.Color, it) }
}
)
}
@Composable
fun RButton(
label: String,
modifier: RModifier = RModifier,
onPress: () -> Unit,
) {
val runtime = LocalReaktorRuntime.current
ComposeNode<ReaktorNode, ReaktorApplier>(
factory = { runtime.createComposeNode(HostType.Button) },
update = {
set(label) { setProp(PropKey.Label, it) }
set(modifier) { setProp(PropKey.Modifier, it) }
update { setEvent(EventKey.Press, onPress) }
}
)
}
@Composable
fun ReactIsland(
module: String,
key: String,
props: Any,
fallback: @Composable () -> Unit = {},
) {
val runtime = LocalReaktorRuntime.current
val parentBoundary = LocalReaktorBoundary.current
val boundary = remember(module, key) {
runtime.createBoundary(
owner = RuntimeOwner.React,
module = module,
key = key,
parent = parentBoundary,
)
}
DisposableEffect(boundary) {
runtime.mountReactBoundary(boundary, module, props)
onDispose {
// React effects dispose before nodes are returned to the arena free list.
runtime.destroyBoundary(boundary, DestroyReason.Unmount)
}
}
BoundaryHostNode(boundary = boundary, fallback = fallback)
}
Compose adapter responsibilities
- Use top-down insertion because HostTree parent attachment is cheap and dirty propagation can be incremental.
- Map composable primitive props into the same generated schemas used by React.
- Register event handlers through native EventRef ids, not raw lambda storage in HostNodes.
- Dispose React child boundaries before freeing the parent subtree.
10. HostTree internals
Generational NodeId and arena
@JvmInline
value class NodeId(val raw: Long) {
val index: Int get() = (raw and 0xffffffffL).toInt()
val generation: Int get() = (raw ushr 32).toInt()
companion object {
fun of(index: Int, generation: Int): NodeId {
return NodeId((generation.toLong() shl 32) or (index.toLong() and 0xffffffffL))
}
}
}
data class HostNode(
val id: NodeId,
var type: HostType,
var parent: NodeId?,
var owner: BoundaryId,
var props: PropsRef = PropsRef.Empty,
var eventMask: Int = 0,
var dirty: DirtyFlags = DirtyFlags.Structure,
val children: ChildBuffer<NodeId> = ChildBuffer.empty(),
)
class NodeArena(initialCapacity: Int = 4096) {
private var nodes = arrayOfNulls<HostNode>(initialCapacity)
private var generations = IntArray(initialCapacity)
private val free = IntArrayStack()
private var size = 0
fun allocate(type: HostType, owner: BoundaryId): HostNode {
val index = if (free.isNotEmpty()) free.pop() else size++
ensureCapacity(index + 1)
val id = NodeId.of(index, generations[index])
return HostNode(id = id, type = type, parent = null, owner = owner).also {
nodes[index] = it
}
}
fun get(id: NodeId): HostNode {
val node = nodes.getOrNull(id.index)
?: error("Unknown node: $id")
check(generations[id.index] == id.generation) { "Stale NodeId: $id" }
return node
}
fun freeSubtree(root: NodeId, visitor: (HostNode) -> Unit = {}) {
val node = get(root)
node.children.forEach { child -> freeSubtree(child, visitor) }
visitor(node)
nodes[root.index] = null
generations[root.index]++
free.push(root.index)
}
}
Adaptive child buffers with hysteresis
interface ChildBuffer<T> {
val size: Int
fun get(index: Int): T
fun insert(index: Int, value: T): ChildBuffer<T>
fun remove(index: Int, count: Int): ChildBuffer<T>
fun move(from: Int, to: Int, count: Int): ChildBuffer<T>
companion object {
fun <T> empty(): ChildBuffer<T> = EmptyChildren
}
}
object ChildBufferPolicy {
// Hysteresis avoids oscillation around a boundary.
const val INLINE_PROMOTE_AT = 5
const val INLINE_DEMOTE_AT = 2
const val GAP_PROMOTE_AT = 129
const val GAP_DEMOTE_AT = 80
fun <T> normalize(buffer: ChildBuffer<T>): ChildBuffer<T> = when (buffer) {
is EmptyChildren -> buffer
is InlineChildren -> if (buffer.size >= INLINE_PROMOTE_AT) GapChildren.from(buffer) else buffer
is GapChildren -> when {
buffer.size <= INLINE_DEMOTE_AT -> InlineChildren.from(buffer)
buffer.size >= GAP_PROMOTE_AT && buffer.nonLocalEditRatio > 0.25 -> ChunkedChildren.from(buffer)
else -> buffer
}
is ChunkedChildren -> if (buffer.size <= GAP_DEMOTE_AT) GapChildren.from(buffer) else buffer
else -> buffer
}
}
Batch memory pool and frame ring
class MutationBatchPool(
private val bufferSize: Int = 64 * 1024,
private val poolSize: Int = 8,
) {
private val pool = ArrayDeque<NativeMutationBuffer>(poolSize)
fun borrow(surfaceId: SurfaceId, boundaryId: BoundaryId, baseRevision: Long): NativeMutationBuffer {
val buffer = if (pool.isEmpty()) {
NativeMutationBuffer(ByteBuffer.allocateDirect(bufferSize))
} else {
pool.removeFirst()
}
return buffer.reset(surfaceId, boundaryId, baseRevision)
}
fun recycle(buffer: NativeMutationBuffer) {
buffer.clearForReuse()
if (pool.size < poolSize) pool.addLast(buffer)
}
}
class FrameCommitRing(capacity: Int = 3) {
// One frame can be rendering, one can be deriving ShadowTree, one can receive commits.
private val slots = Array(capacity) { CommitSlot() }
private val writeIndex = AtomicInteger(0)
fun nextWritable(): CommitSlot = slots[writeIndex.getAndUpdate { (it + 1) % slots.size }]
}
Storage policy
| Concern | Implementation | Reason |
|---|---|---|
| Node identity | Index + generation packed into 64-bit NodeId. | Compact references and stale-handle detection. |
| Children | Empty, inline, gap, chunked, virtual. | Different edit patterns need different sequence structures. |
| Props | Schema-aware props table with patches. | Avoid hot-path generic maps and expensive nested object walks. |
| Batches | Direct ByteBuffer pool and ring of commit slots. | Avoid per-frame allocation churn and renderer contention. |
| Dirty state | Bitsets for structure, props, style, measure, layout, paint, semantics, events, hit-test. | Allows incremental ShadowTree derivation. |
11. ShadowTree, rendering, and event routing
class ShadowPipeline(
private val hostTree: HostTree,
private val styleResolver: StyleResolver,
private val layoutCache: LayoutCache,
private val hitIndex: SpatialHitIndex,
) {
private var committed: ShadowSnapshot = ShadowSnapshot.empty()
private var working: MutableShadowSnapshot = committed.mutableCopy()
fun applyCommit(commit: TreeCommit): ShadowSnapshot {
trace("ShadowPipeline.applyCommit", commit.revision) {
// 1. Update only nodes touched by this commit or marked dirty by propagation.
val dirtyQueue = DirtyQueue(commit.dirtyNodeIds)
while (dirtyQueue.isNotEmpty()) {
val nodeId = dirtyQueue.removeFirst()
val host = hostTree[nodeId]
val oldShadow = working[nodeId]
val nextShadow = deriveShadowNode(host, oldShadow)
working[nodeId] = nextShadow
if (nextShadow.requiresParentLayoutInvalidation(oldShadow)) {
host.parent?.let { dirtyQueue.add(it, DirtyFlags.Layout) }
}
if (nextShadow.hitTestChanged(oldShadow)) {
hitIndex.update(nextShadow)
}
}
// 2. Layout only affected subtrees.
layoutCache.recompute(commit.layoutRoots, working)
// 3. Swap snapshots atomically. Renderers read committed without locking.
committed = working.freeze(revision = commit.revision)
working = committed.mutableCopy(copyDirtyOnly = true)
return committed
}
}
fun currentSnapshot(): ShadowSnapshot = committed
}
data class ShadowNode(
val source: NodeId,
val type: HostType,
val resolvedStyle: ResolvedStyle,
val layout: LayoutBox,
val eventMask: Int,
val semantics: SemanticsNode?,
val owner: BoundaryId,
val dirty: DirtyFlags,
)
Compose renderer v1
@Composable
fun ReaktorComposeRenderer(surfaceId: SurfaceId) {
val runtime = LocalReaktorNativeRuntime.current
val snapshot by runtime.shadowSnapshots(surfaceId).collectAsState()
CompositionLocalProvider(
LocalReaktorEventDispatcher provides runtime.eventDispatcher,
LocalReaktorAccessibilityTree provides snapshot.accessibilityTree,
) {
RenderNode(snapshot = snapshot, nodeId = snapshot.root)
}
}
@Composable
private fun RenderNode(snapshot: ShadowSnapshot, nodeId: NodeId) {
val node = snapshot[nodeId]
val modifier = node.resolvedStyle.toComposeModifier(node.source)
when (node.type) {
HostType.Box -> Box(modifier = modifier.reaktorPointerInput(node)) {
node.children.forEach { RenderNode(snapshot, it) }
}
HostType.Row -> Row(modifier = modifier) {
node.children.forEach { RenderNode(snapshot, it) }
}
HostType.Column -> Column(modifier = modifier) {
node.children.forEach { RenderNode(snapshot, it) }
}
HostType.Text -> Text(
text = node.props.text,
style = node.resolvedStyle.textStyle.toComposeTextStyle(),
modifier = modifier.semanticsFrom(node),
)
HostType.Button -> Button(
onClick = { LocalReaktorEventDispatcher.current.dispatchPress(node.source) },
modifier = modifier.semanticsFrom(node),
) { Text(node.props.label) }
HostType.BoundaryHost -> BoundaryPlaceholder(node.boundaryId, modifier)
}
}
Fast event routing
class EventRouter(
private val shadow: ShadowPipeline,
private val hitIndex: SpatialHitIndex,
private val boundaryRegistry: BoundaryRegistry,
) {
private var lastPointerTarget: CachedTarget? = null
fun onPointerEvent(event: PointerEvent) {
val target = when {
lastPointerTarget?.matches(event) == true -> lastPointerTarget!!.target
else -> hitIndex.findTopMost(event.x, event.y, event.kind).also {
lastPointerTarget = CachedTarget(event.pointerId, event.sequenceId, it)
}
}
val route = shadow.currentSnapshot().eventRoute(target.nodeId, event.kind)
val batches = EventBatchBuilder.groupByBoundary(route, event)
batches.forEach { batch ->
boundaryRegistry.sink(batch.boundaryId).enqueue(batch)
}
}
fun onFocusEvent(nodeId: NodeId, event: FocusEvent) {
// No hit-test. Focus and keyboard events use direct ref routing.
val route = shadow.currentSnapshot().focusRoute(nodeId)
boundaryRegistry.sink(route.boundaryId).enqueue(EventBatch.focus(route, event))
}
}
class GridSpatialHitIndex(
private val cellSizePx: Int = 96,
) : SpatialHitIndex {
private val cells = Long2ObjectOpenHashMap<MutableList<NodeId>>()
override fun update(node: ShadowNode) {
remove(node.source)
if (!node.eventMask.hasPointerEvents()) return
for (cell in cellsFor(node.layout.bounds)) {
cells.getOrPut(cell.key) { mutableListOf() }.add(node.source)
}
}
override fun findTopMost(x: Float, y: Float, kind: PointerKind): HitTarget {
val candidates = cells[cellKey(x, y)].orEmpty()
return candidates
.asSequence()
.map { shadowNode(it) }
.filter { it.layout.bounds.contains(x, y) && it.eventMask.accepts(kind) }
.maxByOrNull { it.layout.zIndex }
?.let { HitTarget(it.source) }
?: HitTarget.Root
}
}
Dirty propagation matrix
| Change | Dirty flags | Renderer effect |
|---|---|---|
| Text content changed | Props, measure, layout, paint, semantics | Remeasure text and affected layout roots only. |
| Color changed | Props, paint | No layout recompute. |
| Child inserted | Structure, layout, semantics, hit-test | Parent subtree layout and spatial index update. |
| Event handler changed | Events | Update EventRef route only. |
| Visibility changed | Style, layout, paint, hit-test, semantics | Remove/insert from hit-test and accessibility projections. |
12. Seamless interop systems
Shared StateCell API
StateCell is the cross-runtime state primitive. React subscribes with useSyncExternalStore; Compose observes a Flow/State. Writes are versioned and batched so both runtimes see a single frame-level update when multiple writes happen in one frame.
import { useSyncExternalStore } from "react";
const snapshots = new Map<StateCellId, { version: number; value: unknown }>();
export function useReaktorState<T>(key: string): [T, (next: T | ((prev: T) => T)) => void] {
const cell = stateRegistry.cellForKey(key);
const value = useSyncExternalStore(
(notify) => globalThis.__ReaktorHost.subscribeStateCell(cell, () => notify()),
() => readCachedSnapshot<T>(cell),
() => readCachedSnapshot<T>(cell),
);
const setValue = (next: T | ((prev: T) => T)) => {
const current = snapshots.get(cell)!;
const resolved = typeof next === "function" ? (next as any)(current.value) : next;
const encoded = stateCodecs.encode(key, resolved);
const newVersion = globalThis.__ReaktorHost.writeStateCell(cell, current.version, encoded);
snapshots.set(cell, { version: newVersion, value: resolved });
};
return [value, setValue];
}
function readCachedSnapshot<T>(cell: StateCellId): T {
const cached = snapshots.get(cell);
const version = stateRegistry.version(cell);
if (cached && cached.version === version) return cached.value as T;
const bytes = globalThis.__ReaktorHost.readStateCell(cell);
const decoded = stateCodecs.decodeCell<T>(cell, bytes);
snapshots.set(cell, { version, value: decoded });
return decoded;
}
@Composable
inline fun <reified T : Any> rememberReaktorState(key: String): State<T> {
val runtime = LocalReaktorRuntime.current
val cell = remember(key) { runtime.stateCells.cellForKey<T>(key) }
return cell.flow.collectAsState(initial = cell.read())
}
class StateCell<T : Any>(
val id: StateCellId,
val schema: Schema<T>,
initialValue: T,
) {
private val mutex = Mutex()
private val _flow = MutableStateFlow(StateVersioned(0, initialValue))
val flow: StateFlow<T> = _flow.map { it.value }.stateInCellScope(initialValue)
suspend fun write(expectedVersion: Int, transform: (T) -> T): Int = mutex.withLock {
val current = _flow.value
check(expectedVersion == current.version) {
"StateCell conflict: expected=$expectedVersion actual=${current.version}"
}
val next = StateVersioned(current.version + 1, transform(current.value))
_flow.value = next
next.version
}
fun read(): T = _flow.value.value
}
Shared animation clock
data class AnimationToken(
val id: String,
val sourceBoundary: BoundaryId,
val targetBoundary: BoundaryId,
val sourceNode: NodeId,
val targetNode: NodeId,
val kind: AnimationKind,
)
class ReaktorAnimationClock(
private val frameClock: MonotonicFrameClock,
) {
private val active = mutableMapOf<String, CrossBoundaryAnimation>()
suspend fun run() {
while (true) {
frameClock.withFrameNanos { now ->
val mutations = active.values.mapNotNull { it.tick(now) }
if (mutations.isNotEmpty()) {
// Animation mutations are high-priority prop/layout overlays.
overlayStore.apply(mutations, priority = ReaktorPriority.Animation)
}
}
}
}
}
// React side
export function useReaktorTransition(tokenId: string) {
return {
start: (from: NodeId, to: NodeId) =>
globalThis.__ReaktorHost.startAnimationToken({ tokenId, from, to }),
cancel: () => globalThis.__ReaktorHost.cancelAnimationToken(tokenId),
};
}
Lifecycle and hot reload
class BoundaryRuntime(
val id: BoundaryId,
val owner: RuntimeOwner,
val rootNode: NodeId,
val scheduler: BoundaryScheduler,
val resources: ResourceScope,
val events: EventSink,
) {
suspend fun dispose(reason: DestroyReason) {
scheduler.stopAcceptingWork()
when (owner) {
RuntimeOwner.React -> reactRuntime.unmountBoundary(id) // flush useEffect cleanup
RuntimeOwner.Compose -> composeRuntime.disposeComposition(id) // DisposableEffect/RememberObserver
RuntimeOwner.Server, RuntimeOwner.Blueprint -> Unit
}
events.close()
resources.closeAll()
hostTree.freeSubtree(rootNode)
}
suspend fun replaceForHotReload(newModule: String) {
scheduler.pause()
val preservedState = stateCells.snapshotForBoundary(id)
reactRuntime.unmountBoundary(id)
hostTree.clearBoundaryChildren(rootNode)
reactRuntime.mountBoundary(id, newModule, preservedState)
scheduler.resume()
}
}
Error handling
class ReaktorBoundaryErrorHandler(
private val runtime: ReaktorRuntime,
) {
fun onReactRenderError(boundaryId: BoundaryId, error: Throwable, componentStack: String?) {
runtime.reportBoundaryError(
BoundaryError(
boundaryId = boundaryId,
source = RuntimeOwner.React,
message = error.message ?: error::class.simpleName ?: "Unknown error",
stack = componentStack,
)
)
runtime.replaceBoundarySubtree(
boundaryId,
PlaceholderSpec.ErrorCard(
title = "This card failed to load",
retryEvent = runtime.events.retryBoundary(boundaryId),
)
)
}
}
// React authoring can still use normal React error boundaries inside the island.
export class ReaktorReactErrorBoundary extends React.Component<
{ boundaryId: BoundaryId; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.setState({ failed: true });
globalThis.__ReaktorHost.reportBoundaryError(
this.props.boundaryId,
encodeError(error, info.componentStack),
);
}
render() {
return this.state.failed ? <RText text="Card unavailable" /> : this.props.children;
}
}
Focus and accessibility
| Problem | Reaktor owner | Implementation |
|---|---|---|
| Tab/focus order across React and Compose | ShadowTree semantics projection | Build a unified focus graph from ShadowNodes, not from runtime ownership. |
| Screen reader traversal | Accessibility tree | Merge semantics nodes across boundaries using layout order and explicit traversal hints. |
| Keyboard/focus events | EventRouter | Direct route to focused EventRef; skip pointer hit-testing. |
| Shared element transition | Animation clock | AnimationToken connects source and target nodes across boundaries. |
| Resource cleanup mismatch | BoundaryRuntime | Dispose owner runtime first, then release event refs/resources, then recycle HostTree nodes. |
13. OTA, server-driven UI, and compliance guardrails
Reaktor should treat remotely delivered React as a signed, capability-scoped UI bundle. The native app should declare the categories of dynamic content it can render, enforce schema compatibility, and expose kill switches and rollout controls. This preserves the product advantage without relying on vague “code push is always allowed” assumptions.
{
"bundleId": "campaign-card",
"version": "2026.05.20-4",
"entry": "campaign-card@1",
"reactVersion": "19.2.0",
"hostSchemaVersion": 3,
"allowedCapabilities": [
"ui.readTheme",
"state.cart.summary.read",
"state.cart.summary.write",
"events.press"
],
"nativeHostTypes": ["RBox", "RText", "RButton", "RImage"],
"integrity": {
"algorithm": "ed25519+sha256",
"signature": "base64...",
"sha256": "base64..."
},
"rollout": {
"channel": "production",
"percentage": 10,
"killSwitchKey": "campaign-card.disable"
}
}
class BundleInstaller(
private val policy: OtaPolicy,
private val verifier: SignatureVerifier,
private val hermes: HermesBundleRuntime,
) {
suspend fun install(manifest: BundleManifest, bytecode: ByteArray): InstallResult {
policy.check(manifest)
verifier.verify(manifest.integrity, bytecode)
check(manifest.hostSchemaVersion <= HostSchema.Current) {
"Bundle requires newer Reaktor host schema"
}
val sandbox = hermes.createSandbox(
bundleId = manifest.bundleId,
capabilities = manifest.allowedCapabilities,
)
sandbox.loadBytecode(bytecode)
registry.register(manifest.entry, sandbox.export(manifest.entry))
return InstallResult.Ready(manifest.entry)
}
}
Server-driven UI modes
| Mode | What server sends | Best for |
|---|---|---|
| Manifest + React bundle | Signed Hermes bytecode/JS bundle plus capability manifest. | Campaigns, A/B tests, personalization, cards with real logic. |
| Typed TreeOps | Precompiled binary mutation stream with no JS execution. | Simple banners, static legal copy, generated forms. |
| Blueprint graph | Design tool graph compiled client-side or server-side to TreeOps. | Visual editor workflows and AI edits. |
Guardrails
- Bundle signing and integrity verification before execution.
- Capability manifest: no arbitrary native calls, no filesystem/network unless explicitly granted.
- Host schema compatibility: bundles declare host primitive/schema versions.
- Kill switch and percentage rollout per bundle.
- Boundary-level error fallback so a failed JS island cannot tear down the native screen.
- App-review notes documenting the dynamic UI category and constraints.
14. Compose-authored UI rendered through React DOM
The web path uses the same HostTree model but swaps the renderer. Compose/Kotlin emits TreeOps into a web HostTree; React DOM subscribes to host snapshots and renders standard DOM nodes or registered React library slots.
export function HostNodeView({ surface, nodeId }: { surface: SurfaceStore; nodeId: NodeId }) {
const node = useHostNode(surface, nodeId);
const children = node.children.map((id) => <HostNodeView key={id} surface={surface} nodeId={id} />);
switch (node.type) {
case "RBox":
return <div data-rid={node.id} style={toCss(node.style)} {...toDomEvents(node)}>{children}</div>;
case "RRow":
return <div data-rid={node.id} style={{ ...toCss(node.style), display: "flex", flexDirection: "row" }}>{children}</div>;
case "RColumn":
return <div data-rid={node.id} style={{ ...toCss(node.style), display: "flex", flexDirection: "column" }}>{children}</div>;
case "RText":
return <span data-rid={node.id} style={toCss(node.style)}>{node.props.text}</span>;
case "RButton":
return <button data-rid={node.id} onClick={() => surface.dispatchPress(node.id)}>{node.props.label}</button>;
case "ReactDomLibrarySlot":
return renderRegisteredLibrary(node.props.component, node.props.props);
}
}
export class ReactDomRenderer {
constructor(private root: import("react-dom/client").Root) {}
render(surface: SurfaceStore) {
this.root.render(<HostNodeView surface={surface} nodeId={surface.rootNodeId} />);
}
}
Web renderer capabilities
DOM-first layout
Use CSS/flex/grid where it is superior to canvas-style web rendering. Avoid duplicating browser layout unless a primitive truly needs custom measurement.
React ecosystem slots
Expose typed ReactDomLibrarySlot nodes for maps, charts, editors, and video players without reimplementing them in Compose.
Hydration path
Server can serialize HostTree snapshots, then React DOM hydrates the projected DOM and reconnects EventRefs.
Same DevTools
Inspector still shows NodeId, boundary owner, props schema, layout, dirty flags, and event routing.
15. Benchmarks and exit criteria
| Benchmark | Scenario | Metric | Purpose |
|---|---|---|---|
| React batch commit | React campaign feed mutates 1k nodes. | JS render time, FFI commit time, native apply time. | Validate batching and prop diff. |
| Compose Applier commit | Compose form recomposes 500 fields. | Recomposition time, mutation count, commit time. | Validate Applier and dirty flags. |
| Shadow incremental derivation | One prop/style/event change in 50k-node tree. | Derived nodes count, layout roots count. | Prove no full shadow walk. |
| Event routing | Pointer move over 500 touch targets. | p50/p95 route latency. | Validate spatial index and target cache. |
| StateCell contention | React and Compose write same cell in one frame. | Conflict rate, observer notifications. | Validate versioning and batching. |
| Boundary teardown | Unmount React island from Compose parent. | Effect cleanup order, leaked refs/resources. | Validate lifecycle protocol. |
| Web renderer | Compose-authored page rendered through React DOM. | Hydration time, DOM node count, update time. | Validate inverse direction. |
Instrumentation
- Trace slices:
ReactRender,ReactCommit,ComposeApply,FfiCommit,TreeApply,ShadowDerive,Layout,Render,EventDispatch. - Attach NodeId, BoundaryId, revision, opCount, bytes, dirty node count, and layout roots to spans.
- Use JMH/Kotlin benchmarks for HostTree/ChildBuffer and Android Macrobenchmark/Perfetto for device traces.
- Run Hermes-side benchmark scripts for batch encoding and event delivery.
16. Build roadmap
Spike React custom renderer lifecycle, Compose Applier lifecycle, and Hermes FFI installation. Pin React/reconciler and Compose versions. Define forbidden private dependencies.
Build NodeArena, ChildBuffer policy, TreeOps, batch pool, validation, fuzz tests, and snapshots.
Implement ReaktorApplier, Compose primitives, ShadowPipeline v1, and Compose renderer. Native-only end-to-end first.
Implement React HostConfig, generated prop diffing, EventRef registry, batch writer, and ReactBoundary mounting from Compose.
StateCell, focus/accessibility graph, animation tokens, error boundaries, lifecycle disposal order, and hot reload.
Render Compose-authored HostTree snapshots through React DOM and register React library slots.
DevTools protocol, Blueprint integration, AI mutation API, Perfetto trace export, docs, templates, and public SDK boundaries.
17. References and design anchors
This blueprint is aligned to the following public integration surfaces and architecture documents.
| Topic | Source | Used for |
|---|---|---|
| React custom renderer | React reconciler README | HostConfig as the renderer boundary. |
| React external store hook | React useSyncExternalStore | React StateCell subscription API. |
| Compose Applier | Android Compose Runtime Applier reference | Compose custom target tree operations. |
| React Native JSI/Fabric | React Native New Architecture, Fabric renderer, Glossary | JSI, ShadowTree, host platform interoperability patterns. |
| Hermes | facebook/hermes | Hermes as the JS runtime target for FFI and bytecode bundles. |
| App Store OTA guardrails | Apple App Review Guidelines | Policy-safe framing of remote interpreted UI logic. |