Reaktor DocsUI and interopCodex

UI and interop · Codex

React × Compose Blueprint

Implementation blueprint for React as control-plane language over native Compose rendering and Compose-authored UI on React DOM.

Use whenUse as the detailed technical design for the React and Compose bridge.
SourceCodex
Route/docs/react-compose

1. Product thesis

Pitch: React gives Reaktor distribution, dynamism, and a massive developer base. Compose gives Reaktor native rendering quality and platform-correct UI. Reaktor is the kernel that lets both ecosystems mutate, render, inspect, and schedule against one typed runtime tree.

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.

React components/hooks Compose functions/remember/snapshots │ │ ▼ ▼ React custom renderer HostConfig Compose Runtime + ReaktorApplier │ │ └──────────── batched TreeOps ────────────┘ │ ▼ reaktor-ffi over Hermes/JSI │ ▼ Native arena-backed HostTree │ ▼ Incremental double-buffered ShadowSnapshot │ ┌────────────────────┼────────────────────┐ ▼ ▼ ▼ Compose renderer React DOM renderer Inspector/AI/Blueprint

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.

Policy framing: this is not an App Store bypass claim. Treat OTA as constrained interpreted business/UI logic with signed bundles, declared capabilities, schema compatibility checks, kill switches, and app-review notes. Do not use remote bundles to materially change the submitted app’s primary purpose.

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

ApproachPrimary problemReaktor position
JSON SDUINo real language, weak state/error handling, one-off DSL per company.React bundles or typed TreeOps provide real logic over native rendering.
React Native aloneDoes not give native Compose ownership and inherits RN platform architecture choices.React is the dynamic authoring layer; Compose remains the native renderer.
Compose Multiplatform aloneWeb target and ecosystem reuse remain weaker than React DOM for many products.Compose model can render to React DOM on web.
FlutterSeparate ecosystem and renderer; cannot reuse React or Compose authoring directly.Renderer swap via HostTree, not wholesale rewrite.
ReaktorRequires kernel engineering and careful lifecycle boundaries.Native rendering + OTA control plane + web ecosystem bridge.

3. Conceptual architecture

Runtime layers

LayerResponsibilityPrimary artifacts
Authoring frontendsIdiomatic React, Compose, server, AI, Blueprint entry points.React components, composables, bundle manifests, graph specs.
Runtime adaptersTranslate reconciler-specific operations to Reaktor TreeOps.React HostConfig, Compose Applier, server compiler.
reaktor-ffiTyped RPC/ABI over Hermes/JSI with low-copy ArrayBuffer batches.HostRpc, event batch callback, state cell RPC.
Mutation engineValidate, compact, order, and apply TreeOps.Batch reader/writer, op coalescer, invariant checker.
HostTreeCanonical retained tree with stable identity and ownership.NodeArena, HostNode, ChildBuffer, PropsRef, EventRef.
Shadow pipelineIncrementally derive renderer-facing snapshots.ShadowNode, dirty propagation, layout cache, hit-test index.
RenderersProject snapshots to platform UI.Compose renderer, React DOM renderer, headless renderer.
Interop servicesMake mixed runtime screens seamless.StateCell, event router, animation clock, focus/accessibility, lifecycle.
ToolingMake runtime complexity visible.Inspector protocol, Perfetto traces, Blueprint bridge, AI mutation API.

Ownership island model

ReaktorSurface(surfaceId=checkout) ├── ComposeBoundary(C1): native shell │ ├── RColumn / RText / RButton │ ├── ReactBoundary(R1): campaign-card@1 │ │ ├── RBox │ │ ├── RImage │ │ └── RButton(onPress = EventRef#17) │ └── ComposeBoundary(C2): payment footer ├── ServerBoundary(S1): legal disclosure modal └── BlueprintBoundary(B1): generated form section

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

package.json for @reaktor/react
{
  "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

build.gradle.kts module skeleton
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

ModuleResponsibilityPublic?
reaktor-tree-coreNodeId, HostType, TreeOp, TreeCommit, BoundaryId, DirtyFlags.Mostly stable
reaktor-tree-storageArena, child buffers, props tables, batch pools, invariant checks.Internal
reaktor-ffiHermes/JSI RPC install, ArrayBuffer transport, native event callbacks.Internal ABI
@reaktor/reactReact primitives, hooks, custom renderer, bundle registration.Public
reaktor-compose-authoringCompose primitives, Applier, ReactIsland/BoundaryHostNode.Public
reaktor-compose-rendererShadowSnapshot to Compose UI projection.Public-ish
@reaktor/react-dom-rendererShadow/Host snapshots to React DOM projection.Public web
reaktor-runtime-interopStateCell, event routing, focus/accessibility, animation clock.Stable contracts
reaktor-devtoolsInspector 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

campaign-card.tsx
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

CheckoutScreen.kt
@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

Compose-authored web target
// 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

FFI design rule: make the ABI fit UI interop, not generic RPC. The hot path is commitBatch(meta, ArrayBuffer); all events and state changes are batched or versioned.

TypeScript ABI exposed to React/Hermes

@reaktor/ffi HostRpc
// @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

ReaktorFfiEndpoint.kt
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

ReaktorHostObject.cpp
// 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

TreeOps binary layout
// 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

MutationBatchWriter.ts
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

TreeStore.apply
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

PatternCompactionSafety rule
Repeated prop updates on same node/key in one frameKeep only final value.Do not collapse event registration side effects until old refs are released.
Remove + insert same node under same parentConvert to MoveChild.Only when ownership boundary is unchanged.
Create + delete before commitDrop entire subtree.Release event refs and props allocations.
Large adjacent insertsUse 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.

Reaktor React HostConfig
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

RBox generated diff
// 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.

ReaktorApplier.kt
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()
    }
}
Compose primitives and ReactIsland
@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

NodeArena.kt
@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

ChildBufferPolicy.kt
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

MutationBatchPool.kt
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

ConcernImplementationReason
Node identityIndex + generation packed into 64-bit NodeId.Compact references and stale-handle detection.
ChildrenEmpty, inline, gap, chunked, virtual.Different edit patterns need different sequence structures.
PropsSchema-aware props table with patches.Avoid hot-path generic maps and expensive nested object walks.
BatchesDirect ByteBuffer pool and ring of commit slots.Avoid per-frame allocation churn and renderer contention.
Dirty stateBitsets for structure, props, style, measure, layout, paint, semantics, events, hit-test.Allows incremental ShadowTree derivation.

11. ShadowTree, rendering, and event routing

Incorporated review point: ShadowTree is not rebuilt wholesale. Commits feed dirty node ids into an incremental derivation pipeline, and renderers read immutable committed snapshots while the next working snapshot is prepared.
Incremental double-buffered ShadowPipeline
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

ShadowSnapshot → Compose UI
@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

EventRouter and spatial hit index
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

ChangeDirty flagsRenderer effect
Text content changedProps, measure, layout, paint, semanticsRemeasure text and affected layout roots only.
Color changedProps, paintNo layout recompute.
Child insertedStructure, layout, semantics, hit-testParent subtree layout and spatial index update.
Event handler changedEventsUpdate EventRef route only.
Visibility changedStyle, layout, paint, hit-test, semanticsRemove/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.

React useReaktorState
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;
}
Compose rememberReaktorState
@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

Cross-boundary animation tokens
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

BoundaryRuntime disposal/replacement
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

Reaktor-level boundary errors
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

ProblemReaktor ownerImplementation
Tab/focus order across React and ComposeShadowTree semantics projectionBuild a unified focus graph from ShadowNodes, not from runtime ownership.
Screen reader traversalAccessibility treeMerge semantics nodes across boundaries using layout order and explicit traversal hints.
Keyboard/focus eventsEventRouterDirect route to focused EventRef; skip pointer hit-testing.
Shared element transitionAnimation clockAnimationToken connects source and target nodes across boundaries.
Resource cleanup mismatchBoundaryRuntimeDispose 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.

Signed bundle manifest
{
  "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"
  }
}
BundleInstaller.kt
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

ModeWhat server sendsBest for
Manifest + React bundleSigned Hermes bytecode/JS bundle plus capability manifest.Campaigns, A/B tests, personalization, cards with real logic.
Typed TreeOpsPrecompiled binary mutation stream with no JS execution.Simple banners, static legal copy, generated forms.
Blueprint graphDesign 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.

React DOM renderer
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

BenchmarkScenarioMetricPurpose
React batch commitReact campaign feed mutates 1k nodes.JS render time, FFI commit time, native apply time.Validate batching and prop diff.
Compose Applier commitCompose form recomposes 500 fields.Recomposition time, mutation count, commit time.Validate Applier and dirty flags.
Shadow incremental derivationOne prop/style/event change in 50k-node tree.Derived nodes count, layout roots count.Prove no full shadow walk.
Event routingPointer move over 500 touch targets.p50/p95 route latency.Validate spatial index and target cache.
StateCell contentionReact and Compose write same cell in one frame.Conflict rate, observer notifications.Validate versioning and batching.
Boundary teardownUnmount React island from Compose parent.Effect cleanup order, leaked refs/resources.Validate lifecycle protocol.
Web rendererCompose-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

Phase 0 — Probes and version pinning
1–2 weeks

Spike React custom renderer lifecycle, Compose Applier lifecycle, and Hermes FFI installation. Pin React/reconciler and Compose versions. Define forbidden private dependencies.

React HostConfig log rendererCompose Applier log treeHermes FFI hello batch
Phase 1 — HostTree + mutation protocol
3–5 weeks

Build NodeArena, ChildBuffer policy, TreeOps, batch pool, validation, fuzz tests, and snapshots.

100k node benchmark1M op fuzz testsnapshot export
Phase 2 — Compose authoring + Compose renderer
4–6 weeks

Implement ReaktorApplier, Compose primitives, ShadowPipeline v1, and Compose renderer. Native-only end-to-end first.

RText/RBox/RButtonincremental shadownative render demo
Phase 3 — React renderer over Hermes FFI
5–8 weeks

Implement React HostConfig, generated prop diffing, EventRef registry, batch writer, and ReactBoundary mounting from Compose.

campaign card demosigned bundle loadevent roundtrip
Phase 4 — Seamlessness layer
4–7 weeks

StateCell, focus/accessibility graph, animation tokens, error boundaries, lifecycle disposal order, and hot reload.

shared cart statefocus traversalboundary hot reload
Phase 5 — Web renderer
4–6 weeks

Render Compose-authored HostTree snapshots through React DOM and register React library slots.

React DOM projectionchart/map slotweb inspector
Phase 6 — Tooling and productization
ongoing

DevTools protocol, Blueprint integration, AI mutation API, Perfetto trace export, docs, templates, and public SDK boundaries.

DevTools treeBlueprint bridgepublic alpha

17. References and design anchors

This blueprint is aligned to the following public integration surfaces and architecture documents.

TopicSourceUsed for
React custom rendererReact reconciler READMEHostConfig as the renderer boundary.
React external store hookReact useSyncExternalStoreReact StateCell subscription API.
Compose ApplierAndroid Compose Runtime Applier referenceCompose custom target tree operations.
React Native JSI/FabricReact Native New Architecture, Fabric renderer, GlossaryJSI, ShadowTree, host platform interoperability patterns.
Hermesfacebook/hermesHermes as the JS runtime target for FFI and bytecode bundles.
App Store OTA guardrailsApple App Review GuidelinesPolicy-safe framing of remote interpreted UI logic.
North star: React Fiber and Compose Runtime remain independent reconcilers, but their host-level mutations are compiled into the same native Reaktor mutation buffer, applied to an arena-backed HostTree, and projected through incremental ShadowSnapshots to native Compose, React DOM, tools, Blueprint, and AI agents.