From 35abaa86d98309816f353adb72b761645497684b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 9 Oct 2026 17:11:05 +0200 Subject: [PATCH 1/3] spike(uniffi): the Rust palette on Expo through uniffi-bindgen-react-native `crates/uniffi` gets the push protocol `crates/wasm` speaks: `attach(host)` with a `notify(state, lo, hi)` per change and timers as host calls, next to the pull API (`send`/`send_json` push, `send_kind`/`send_payload_json` pull). The crate is its own cargo workspace so it builds from a git worktree too. `sandbox/uniffi` is the turbo module ubrn generates from it (config only; the glue, the TS bindings and the xcframework are built by `pnpm ubrn:ios` and ignored). The Expo sandbox depends on it and times TS vs Rust pull vs Rust push per event in Hermes (`rust-bench.tsx`); the findings and the numbers are in TODO.md. Along the way: Expo's tsconfig-paths loader is off (it needs the TypeScript JS API, gone in TS 7) and `babel-preset-expo` is an explicit devDependency (pnpm does not hoist it for the release bundle step). Co-Authored-By: Claude Fable 5.1 --- TODO.md | 33 +++-- crates/uniffi/Cargo.toml | 7 +- crates/uniffi/src/lib.rs | 120 +++++++++++++++--- pnpm-lock.yaml | 88 +++++++++++++ sandbox/native/app.json | 12 +- sandbox/native/app.tsx | 2 + sandbox/native/metro.config.js | 2 + sandbox/native/package.json | 4 +- sandbox/native/rust-bench.tsx | 211 ++++++++++++++++++++++++++++++++ sandbox/native/tsconfig.json | 2 +- sandbox/uniffi/.gitignore | 8 ++ sandbox/uniffi/README.md | 14 +++ sandbox/uniffi/package.json | 34 +++++ sandbox/uniffi/ubrn.config.yaml | 8 ++ 14 files changed, 516 insertions(+), 29 deletions(-) create mode 100644 sandbox/native/rust-bench.tsx create mode 100644 sandbox/uniffi/.gitignore create mode 100644 sandbox/uniffi/README.md create mode 100644 sandbox/uniffi/package.json create mode 100644 sandbox/uniffi/ubrn.config.yaml diff --git a/TODO.md b/TODO.md index be66589..d104212 100644 --- a/TODO.md +++ b/TODO.md @@ -5,13 +5,32 @@ ran on the Rust engine, is on the local branch `rust-engine-under-ts` (head `4e4 ## Rust machines on React Native (JSI) -- [ ] `crates/uniffi` is a Rust machine as a JS object over JSI (today: the palette - only). It needs the same facade `fromWasm` gives a wasm class: the TS `Machine` - interface over the handle. Then `connect()`, the bindings and `packages/native` - work unchanged; it does not need to mirror `packages/shared/bindings`, which sit - above the machine. -- [ ] Rework the handle onto the protocol `crates/wasm` speaks (notify per change, - timers as host calls) instead of JSON strings and a pulled change mask. +Spiked on 2026-10-09: `crates/uniffi` runs in the Expo sandbox through +uniffi-bindgen-react-native (`sandbox/uniffi`, a turbo module Expo autolinks into a dev +build), in push mode (`attach(host)`: a `notify(state, lo, hi)` per change and timers as +host calls, as `crates/wasm` speaks) next to the pull mode. The sandbox app times both +against the TS engine (`sandbox/native/rust-bench.tsx`). Hermes, Release build, iPhone 17 +simulator, per event: + +| Event | TS | Rust pull | Rust push | +| -------------------------- | -----: | --------: | --------: | +| execute (no payload) | 315 ns | 1,596 ns | 1,446 ns | +| query.set (JSON payload) | 982 ns | 6,365 ns | 7,618 ns | +| bare call (`stateIndex()`) | | 1,130 ns | | + +- The floor is the generated wrapper, not the engine: each method is two FFI calls (an + `Arc` clone, then the call) plus the status object and closures the generated TS + allocates. Push and pull cost the same; a notify back into JS adds ~0.3 µs. +- A JSON payload adds ~5 µs: the string is lowered, then parsed by serde. +- So uniffi does not get close to wasm, where a Rust machine runs within ~1.3× of the TS + engine. On React Native the TS engine stays the default: 315 ns per event is far below + any frame budget. + +- [ ] If Rust on React Native is still wanted: a hand-written JSI host object over the + handle's protocol (no per-call clone, no status allocation, typed payloads), or + ubrn's `jsi2` flavor — neither measured. +- [ ] Only then, the TS facade over the handle (what `fromWasm` is to a wasm class), so + `connect()`, the bindings and `packages/native` work unchanged. ## Rust machines in JS (wasm) — done, notes diff --git a/crates/uniffi/Cargo.toml b/crates/uniffi/Cargo.toml index befe3a7..b476f31 100644 --- a/crates/uniffi/Cargo.toml +++ b/crates/uniffi/Cargo.toml @@ -1,6 +1,9 @@ # React Native binding of the engine, built by uniffi-bindgen-react-native. -# Standalone on purpose: the root workspace excludes it, so its mobile-only -# toolchain can never break the other crates' builds. +# Standalone on purpose: its own (empty) workspace, so its mobile-only toolchain can +# never break the other crates' builds, and so it builds from a git worktree too (cargo +# would otherwise walk up to the main checkout's workspace). +[workspace] + [package] name = "dunky-uniffi" version = "0.1.0" diff --git a/crates/uniffi/src/lib.rs b/crates/uniffi/src/lib.rs index 70c2203..deca963 100644 --- a/crates/uniffi/src/lib.rs +++ b/crates/uniffi/src/lib.rs @@ -5,12 +5,14 @@ //! flat commands (`dunky_core::protocol`). Values cross as JSON strings instead of JS //! values. It still needs a TS facade over this handle (like `fromWasm` for wasm). +use std::cell::RefCell; use std::mem::ManuallyDrop; use std::sync::Arc; use std::thread::{self, ThreadId}; use dunky_core::{ - Config, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, Types, + Command, Config, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, + Subscription, Types, }; use dunky_sandbox::palette::{self, Palette, PaletteEvent}; @@ -85,6 +87,26 @@ fn event_name(kind: u32) -> Result<&'static str, MachineError> { .ok_or_else(|| MachineError::Invalid(format!("[machine] no event type #{kind}"))) } +/// The payload-less palette event at `kind`. +fn unit_event(kind: u32) -> Result { + let name = event_name::(kind)?; + PaletteEvent::from_kind(PaletteEvent::kind_from_index(kind as usize)).ok_or_else(|| { + MachineError::Invalid(format!( + "[machine] event \"{name}\" carries data; use sendEvent" + )) + }) +} + +/// The palette event at `kind` with its fields parsed from `json`. +fn payload_event(kind: u32, json: &str) -> Result { + let name = event_name::(kind)?; + let bad = |e: serde_json::Error| { + MachineError::Invalid(format!("[machine] bad \"{name}\" event: {e}")) + }; + let payload: serde_json::Value = serde_json::from_str(json).map_err(bad)?; + PaletteEvent::deserialize_payload(kind as usize, payload).map_err(bad) +} + /// Appends field `index` as JSON. `None` is `null`, matching the TS machines. fn write_field(context: &C, index: usize, out: &mut Vec) { let start = out.len(); @@ -141,10 +163,56 @@ thread_local! { static PALETTE: Config = palette::config(Vec::new()); } +/// What a machine calls on the native side (the push protocol, as `crates/wasm` speaks +/// to JS): a notify per change, in order, and timers on the host's clock. +#[uniffi::export(callback_interface)] +pub trait MachineHost: Send + Sync { + /// Once per effective change: the state, and the fields changed since the last notify. + fn notify(&self, state: u32, lo: u32, hi: u32); + fn start_timer(&self, id: u32, ms: u32); + fn cancel_timer(&self, id: u32); +} + +/// The attached host, and the engine subscription that feeds its `notify`. +struct Attached { + host: Arc, + changes: Subscription, +} + /// The sandbox command palette (`dunky_sandbox::palette`), behind the handle protocol. #[derive(uniffi::Object)] pub struct PaletteMachine { machine: ThreadBound>, + attached: ThreadBound>>, +} + +impl PaletteMachine { + /// Push mode: run the timer commands the last call emitted on the host's clock. + fn run_timers(&self) { + let attached = self.attached.get().borrow(); + let Some(attached) = attached.as_ref() else { + return; + }; + let machine = self.machine.get(); + if !machine.has_commands() { + return; + } + for command in machine.take_commands() { + match command { + Command::StartTimer { id, ms } => attached.host.start_timer(id, ms), + Command::CancelTimer { id } => attached.host.cancel_timer(id), + } + } + } +} + +impl Drop for PaletteMachine { + fn drop(&mut self) { + // The subscription holds the machine: undo it, so both are freed. + if let Some(attached) = self.attached.get().borrow_mut().take() { + attached.changes.unsubscribe(); + } + } } #[uniffi::export] @@ -158,34 +226,54 @@ impl PaletteMachine { PALETTE.with(|config| Machine::with_context(config, palette::context(commands))); Ok(Arc::new(Self { machine: ThreadBound::new(machine), + attached: ThreadBound::new(RefCell::new(None)), })) } + /// Push mode: the machine notifies `host` once per change and runs its timers there. + pub fn attach(&self, host: Box) { + let host: Arc = Arc::from(host); + let machine = self.machine.get().clone(); + let notify = host.clone(); + let changes = self.machine.get().subscribe(move || { + let changes = machine.take_changes(); + let fields = changes.fields; + notify.notify( + machine.state().index() as u32, + fields as u32, + (fields >> 32) as u32, + ); + }); + *self.attached.get().borrow_mut() = Some(Attached { host, changes }); + self.run_timers(); + } + + /// Push mode: a payload-less event by kind index; changes arrive through the host. + pub fn send(&self, kind: u32) -> Result<(), MachineError> { + self.machine.get().send(unit_event(kind)?); + self.run_timers(); + Ok(()) + } + + /// Push mode: an event with a payload, by kind index; `json` holds its fields. + pub fn send_json(&self, kind: u32, json: String) -> Result<(), MachineError> { + self.machine.get().send(payload_event(kind, &json)?); + self.run_timers(); + Ok(()) + } + /// Payload-less event by kind index. Returns the change mask. pub fn send_kind(&self, kind: u32) -> Result { - let name = event_name::(kind)?; - let event = PaletteEvent::from_kind(PaletteEvent::kind_from_index(kind as usize)) - .ok_or_else(|| { - MachineError::Invalid(format!( - "[machine] event \"{name}\" carries data; use sendEvent" - )) - })?; let machine = self.machine.get(); - machine.send(event); + machine.send(unit_event(kind)?); Ok(take_change_mask(machine)) } /// An event with a payload, by kind index; `json` holds its fields. Returns the /// change mask. pub fn send_payload_json(&self, kind: u32, json: String) -> Result { - let name = event_name::(kind)?; - let bad = |e: serde_json::Error| { - MachineError::Invalid(format!("[machine] bad \"{name}\" event: {e}")) - }; - let payload: serde_json::Value = serde_json::from_str(&json).map_err(bad)?; - let event = PaletteEvent::deserialize_payload(kind as usize, payload).map_err(bad)?; let machine = self.machine.get(); - machine.send(event); + machine.send(payload_event(kind, &json)?); Ok(take_change_mask(machine)) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a75981b..248aa64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: '@sandbox/cmdk-core': specifier: workspace:^ version: link:../shared + '@sandbox/uniffi': + specifier: workspace:^ + version: link:../uniffi expo: specifier: ^54.0.0 version: 54.0.35(@babel/core@7.29.7)(react-native@0.81.4(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@7.0.2) @@ -277,6 +280,9 @@ importers: '@types/react': specifier: ~19.1.0 version: 19.1.17 + babel-preset-expo: + specifier: ~54.0.11 + version: 54.0.11(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@54.0.35(@babel/core@7.29.7)(react-native@0.81.4(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@7.0.2))(react-refresh@0.14.2) sandbox/opentui: dependencies: @@ -404,6 +410,21 @@ importers: specifier: ^3.0.0-next.27 version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + sandbox/uniffi: + dependencies: + '@ubjs/core': + specifier: 0.31.0-6 + version: 0.31.0-6 + react: + specifier: '*' + version: 19.2.6 + react-native: + specifier: '*' + version: 0.81.4(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6) + uniffi-bindgen-react-native: + specifier: 0.31.0-6 + version: 0.31.0-6 + website: dependencies: '@astrojs/starlight': @@ -3392,6 +3413,9 @@ packages: cpu: [x64] os: [win32] + '@ubjs/core@0.31.0-6': + resolution: {integrity: sha512-pscv6Zj8mVmd5+ikb1Zr6XZ0yqYwKGeIV8HU8rf9sTvs7GW34OARAp03xFJ7rE9XE4R6LgwQhzYW9knAuz5yxA==} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -6697,6 +6721,10 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + uniffi-bindgen-react-native@0.31.0-6: + resolution: {integrity: sha512-4SfjPl8b9oQQcLJRH7INa+DdWV2FKu9HAVOjHL0+rT8EniTG4qoxOdmDBw8Novq7vSqEFmiXbHaoArLFqWWEhg==} + hasBin: true + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -9558,6 +9586,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 + '@react-native/virtualized-lists@0.81.4(@types/react@19.2.15)(react-native@0.81.4(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.81.4(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + '@react-native/virtualized-lists@0.85.0(@types/react@19.2.15)(react-native@0.85.0(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6))(react@19.2.6)': dependencies: invariant: 2.2.4 @@ -10115,6 +10152,8 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@ubjs/core@0.31.0-6': {} + '@ungap/structured-clone@1.3.1': {} '@urql/core@5.2.0': @@ -13617,6 +13656,53 @@ snapshots: - supports-color - utf-8-validate + react-native@0.81.4(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.81.4 + '@react-native/codegen': 0.81.4(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.81.4 + '@react-native/gradle-plugin': 0.81.4 + '@react-native/js-polyfills': 0.81.4 + '@react-native/normalize-colors': 0.81.4 + '@react-native/virtualized-lists': 0.81.4(@types/react@19.2.15)(react-native@0.81.4(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6))(react@19.2.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.7) + babel-plugin-syntax-hermes-parser: 0.29.1 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.6 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.26.0 + semver: 7.8.1 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 6.2.4 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.15 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + react-native@0.85.0(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6): dependencies: '@react-native/assets-registry': 0.85.0 @@ -14494,6 +14580,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + uniffi-bindgen-react-native@0.31.0-6: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 diff --git a/sandbox/native/app.json b/sandbox/native/app.json index 9563fad..6fd6f59 100644 --- a/sandbox/native/app.json +++ b/sandbox/native/app.json @@ -6,7 +6,15 @@ "orientation": "portrait", "userInterfaceStyle": "light", "newArchEnabled": true, - "ios": { "supportsTablet": true, "bundleIdentifier": "dev.dunky.cmdk" }, - "android": { "package": "dev.dunky.cmdk" } + "ios": { + "supportsTablet": true, + "bundleIdentifier": "dev.dunky.cmdk" + }, + "android": { + "package": "dev.dunky.cmdk" + }, + "experiments": { + "tsconfigPaths": false + } } } diff --git a/sandbox/native/app.tsx b/sandbox/native/app.tsx index b9794fe..b105232 100644 --- a/sandbox/native/app.tsx +++ b/sandbox/native/app.tsx @@ -3,6 +3,7 @@ import { Alert, StyleSheet, Text, View } from 'react-native' import { StatusBar } from 'expo-status-bar' import { DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' +import { RustBench } from './rust-bench' export default function App() { const [last, setLast] = useState('—') @@ -23,6 +24,7 @@ export default function App() { The same machine + connect runs the terminal (OpenTUI) and React Native versions Last selected: {last} + ) } diff --git a/sandbox/native/metro.config.js b/sandbox/native/metro.config.js index 4746090..a906abb 100644 --- a/sandbox/native/metro.config.js +++ b/sandbox/native/metro.config.js @@ -1,6 +1,8 @@ // Monorepo Metro config: watch the workspace root so Metro picks up the linked // @dunky.dev/* + @sandbox/* source packages, and resolve modules from both the // app's and the root's node_modules. Mirrors the standard Expo monorepo setup. +// (app.json turns Expo's tsconfig-paths loader off: it needs the TypeScript JS API, +// which TypeScript 7 no longer ships; the packages resolve by `main` anyway.) const { getDefaultConfig } = require('expo/metro-config') const path = require('node:path') diff --git a/sandbox/native/package.json b/sandbox/native/package.json index eab895a..7155a96 100644 --- a/sandbox/native/package.json +++ b/sandbox/native/package.json @@ -15,6 +15,7 @@ "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", "@sandbox/cmdk-core": "workspace:^", + "@sandbox/uniffi": "workspace:^", "expo": "^54.0.0", "expo-status-bar": "~3.0.0", "react": "19.1.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@babel/core": "^7.25.0", - "@types/react": "~19.1.0" + "@types/react": "~19.1.0", + "babel-preset-expo": "~54.0.11" } } diff --git a/sandbox/native/rust-bench.tsx b/sandbox/native/rust-bench.tsx new file mode 100644 index 0000000..d849ac1 --- /dev/null +++ b/sandbox/native/rust-bench.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { toMachine } from '@dunky.dev/state-machine' +import { commandPaletteMachineConfig, DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { type MachineHost, PaletteMachine } from '@sandbox/uniffi' + +// The uniffi spike: what one event costs in Hermes on each path, side by side. +// +// - TS: the TS engine, a subscriber reading the changed field. +// - Rust pull: `sendKind` returns a change mask; JS reads the field named in it. +// - Rust push: the machine calls `host.notify(state, lo, hi)` per change, as the +// wasm binding does; JS reads the field inside the notify. +// +// Each run is a fresh machine, open with results, timed with `performance.now()`. +// The medians of three runs are shown; the raw JSON goes to the Metro log as +// `[rust-bench]`. + +const EVENTS = 50_000 +const RUNS = 3 + +// Kind indices (`metaJson().events`) and field indices (`metaJson().fields`). +const OPEN = 0 +const QUERY_SET = 2 +const EXECUTE = 5 +const QUERY = 1 +const LAST_EXECUTED = 3 +// The pull mask: field `i` is bit `2 + i` (`dunky_core::protocol`). +const FIELD_SHIFT = 2 + +interface Result { + label: string + nsPerEvent: number +} + +const COMMANDS_JSON = JSON.stringify(DEMO_COMMANDS) + +function openRustPalette(): PaletteMachine { + const m = new PaletteMachine(COMMANDS_JSON) + m.start() + m.sendKind(OPEN) + return m +} + +function openTsPalette() { + const m = toMachine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) + m.start() + m.send({ type: 'open' }) + return m +} + +/** Time `events` events: `setup()` builds the machine, `each(i)` sends one. */ +function measure(label: string, setup: () => (i: number) => void): Result { + const samples: number[] = [] + for (let run = 0; run < RUNS; run++) { + const each = setup() + for (let i = 0; i < EVENTS / 10; i++) each(i) // warm up + const start = performance.now() + for (let i = 0; i < EVENTS; i++) each(i) + samples.push(((performance.now() - start) * 1e6) / EVENTS) + } + samples.sort((a, b) => a - b) + return { label, nsPerEvent: samples[RUNS >> 1]! } +} + +/** A host that reads the named field inside each notify. */ +function readingHost(m: PaletteMachine, field: number, sink: { reads: number }): MachineHost { + const bit = 1 << field + return { + notify: (_state, lo) => { + if (lo & bit) { + JSON.parse(m.fieldJson(field)) + sink.reads++ + } + }, + startTimer: () => {}, + cancelTimer: () => {}, + } +} + +function runAll(): Result[] { + const sink = { reads: 0 } + const results: Result[] = [] + + // One payload-less event that changes one field: `execute` stamps `lastExecuted`. + results.push( + measure('TS · execute', () => { + const m = openTsPalette() + m.subscribe(() => { + if (m.context.lastExecuted) sink.reads++ + }) + return () => m.send({ type: 'execute' }) + }), + measure('Rust pull · execute', () => { + const m = openRustPalette() + const bit = 1 << (FIELD_SHIFT + LAST_EXECUTED) + return () => { + const mask = m.sendKind(EXECUTE) + if (mask & bit) { + JSON.parse(m.fieldJson(LAST_EXECUTED)) + sink.reads++ + } + } + }), + measure('Rust push · execute', () => { + const m = openRustPalette() + m.attach(readingHost(m, LAST_EXECUTED, sink)) + return () => m.send(EXECUTE) + }), + ) + + // One event with a payload that changes one field: `query.set` toggling the query. + const queries = ['{"query":"g"}', '{"query":""}'] + results.push( + measure('TS · query.set', () => { + const m = openTsPalette() + m.subscribe(() => { + if (m.context.query.length >= 0) sink.reads++ + }) + return i => m.send({ type: 'query.set', query: i & 1 ? 'g' : '' }) + }), + measure('Rust pull · query.set', () => { + const m = openRustPalette() + const bit = 1 << (FIELD_SHIFT + QUERY) + return i => { + const mask = m.sendPayloadJson(QUERY_SET, queries[i & 1]!) + if (mask & bit) { + JSON.parse(m.fieldJson(QUERY)) + sink.reads++ + } + } + }), + measure('Rust push · query.set', () => { + const m = openRustPalette() + m.attach(readingHost(m, QUERY, sink)) + return i => m.sendJson(QUERY_SET, queries[i & 1]!) + }), + ) + + // The floor: what a call into Rust costs with nothing to do, and what one notify + // back costs on top of the event. + results.push( + measure('Rust · stateIndex() (bare call)', () => { + const m = openRustPalette() + return () => { + sink.reads += m.stateIndex() + } + }), + measure('Rust push · execute, notify not reading', () => { + const m = openRustPalette() + m.attach({ + notify: () => { + sink.reads++ + }, + startTimer: () => {}, + cancelTimer: () => {}, + }) + return () => m.send(EXECUTE) + }), + ) + + console.log(`[rust-bench] ${JSON.stringify({ events: EVENTS, runs: RUNS, results })}`) + return results +} + +export function RustBench() { + const [results, setResults] = useState(null) + const [running, setRunning] = useState(false) + + const run = () => { + setRunning(true) + // Let the frame paint before Hermes blocks on the loop. + setTimeout(() => { + setResults(runAll()) + setRunning(false) + }, 50) + } + + useEffect(run, []) + + return ( + + Rust over uniffi, per event (ns, median of {RUNS}) + {running && running {EVENTS.toLocaleString()} events…} + {results?.map(r => ( + + {r.label} + {Math.round(r.nsPerEvent).toLocaleString()} + + ))} + + Run again + + + ) +} + +const styles = StyleSheet.create({ + card: { + alignSelf: 'stretch', + gap: 6, + padding: 16, + borderRadius: 12, + backgroundColor: '#f4f5f8', + }, + heading: { fontSize: 13, fontWeight: '700', color: '#1c1e26', marginBottom: 4 }, + line: { flexDirection: 'row', justifyContent: 'space-between' }, + row: { fontSize: 13, color: '#5b6172' }, + value: { fontSize: 13, fontVariant: ['tabular-nums'], color: '#1c1e26' }, + button: { alignSelf: 'flex-start', marginTop: 6 }, + buttonText: { fontSize: 13, fontWeight: '700', color: '#6c5ce7' }, +}) diff --git a/sandbox/native/tsconfig.json b/sandbox/native/tsconfig.json index 3a85ccd..e96c663 100644 --- a/sandbox/native/tsconfig.json +++ b/sandbox/native/tsconfig.json @@ -4,5 +4,5 @@ "jsx": "react-jsx", "types": ["react"] }, - "include": ["index.ts", "app.tsx", "command-palette.tsx"] + "include": ["index.ts", "app.tsx", "command-palette.tsx", "rust-bench.tsx"] } diff --git a/sandbox/uniffi/.gitignore b/sandbox/uniffi/.gitignore new file mode 100644 index 0000000..b957e29 --- /dev/null +++ b/sandbox/uniffi/.gitignore @@ -0,0 +1,8 @@ +# Everything but the config is generated by `pnpm ubrn:ios` (see README.md): the +# turbo-module glue, the TS bindings and the Rust xcframework. +/cpp +/ios +/android +/src +/*.podspec +/*.xcframework diff --git a/sandbox/uniffi/README.md b/sandbox/uniffi/README.md new file mode 100644 index 0000000..d5cf6bb --- /dev/null +++ b/sandbox/uniffi/README.md @@ -0,0 +1,14 @@ +# @sandbox/uniffi + +The sandbox palette written in Rust (`crates/uniffi`) as a React Native turbo module, +generated by [uniffi-bindgen-react-native](https://github.com/jhugman/uniffi-bindgen-react-native) +(ubrn). `sandbox/native` depends on it; Expo autolinks it into the dev build. + +Only `package.json` and `ubrn.config.yaml` are source. Everything else is generated: + +```bash +pnpm --filter @sandbox/uniffi ubrn:ios # Rust for the iOS simulator + C++/TS glue +cd sandbox/native && pnpm ios # an Expo dev build (not Expo Go) +``` + +Android is out of scope for now: the config builds the simulator target only. diff --git a/sandbox/uniffi/package.json b/sandbox/uniffi/package.json new file mode 100644 index 0000000..3491922 --- /dev/null +++ b/sandbox/uniffi/package.json @@ -0,0 +1,34 @@ +{ + "name": "@sandbox/uniffi", + "version": "0.0.0", + "private": true, + "description": "The sandbox palette in Rust (crates/uniffi) as a React Native turbo module, generated by uniffi-bindgen-react-native.", + "homepage": "https://github.com/dunky-dev/state-machine", + "license": "MIT", + "author": "Dunky", + "repository": { + "url": "https://github.com/dunky-dev/state-machine" + }, + "main": "src/index.tsx", + "react-native": "src/index.tsx", + "scripts": { + "ubrn:ios": "ubrn build ios --sim-only --release --and-generate", + "ubrn:clean": "rm -rf cpp ios android src/Native* src/index.tsx src/generated *.podspec" + }, + "dependencies": { + "@ubjs/core": "0.31.0-6", + "uniffi-bindgen-react-native": "0.31.0-6" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "codegenConfig": { + "name": "DunkyUniffiSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "dev.dunky.uniffi" + } + } +} diff --git a/sandbox/uniffi/ubrn.config.yaml b/sandbox/uniffi/ubrn.config.yaml new file mode 100644 index 0000000..2f38a00 --- /dev/null +++ b/sandbox/uniffi/ubrn.config.yaml @@ -0,0 +1,8 @@ +# uniffi-bindgen-react-native: the Rust crate is crates/uniffi; the simulator build is +# all the sandbox needs (`pnpm ubrn:ios`). +rust: + directory: ../../crates/uniffi + manifestPath: Cargo.toml +ios: + targets: + - aarch64-apple-ios-sim From 011749aa8901e34df672640eafa9e42096658993 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 9 Oct 2026 17:45:04 +0200 Subject: [PATCH 2/3] docs: the target architecture, with the Rust render path ARCHITECTURE.md draws the two-engine shape end to end: engines synced by spec, machines with their connectors, the bindings (wasm, uniffi) and the facades that put a Rust machine behind the TS Machine interface, the shared bindings and the targets, and the Rust column above the engine (a connector per machine, a shared bindings crate, one iced target spanning macOS, Windows and Linux). TODO.md records the decision: the TS engine stays the React Native default, Rust-written machines are consumed over wasm and uniffi behind a facade, and lists what the facade needs. Co-Authored-By: Claude Fable 5.1 --- ARCHITECTURE.md | 121 ++++++++++++++++++++---- TODO.md | 39 ++++++-- website/src/content/docs/rust/index.mdx | 16 ++-- 3 files changed, 137 insertions(+), 39 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e43cfd9..1f239ed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,7 +104,8 @@ Zag, whose machines read props directly.) | `crates/wasm/` | Rust machines as JS classes (`export_machine!`) | | `packages/wasm/` | `fromWasm`: a Rust machine behind the TS `Machine` interface | | `crates/sandbox/` | The sandbox machines in Rust, twins of `sandbox/shared` ones | -| `crates/uniffi/` | The Rust engine for React Native over JSI (in progress) | +| `crates/uniffi/` | Rust machines for React Native over JSI (uniffi; facade planned) | +| `sandbox/uniffi/` | The turbo module uniffi-bindgen-react-native generates from it | ## The map @@ -146,29 +147,107 @@ Three package groups, three jobs: ## Two engines, one spec +The machine behavior exists twice, in TypeScript and in Rust, against one contract. +This is the target shape of the repo; the boxes marked _planned_ do not exist yet. + ``` - packages/core/SPEC.md - (the one behavior contract) - / \ - v v -packages/core (TypeScript) crates/core (Rust) -the engine for JS and every the engine for Rust programs and -target; pure TS, no wasm native bindings (crates/uniffi) - | | -packages/core/tests <-- twins --> crates/core/tests + packages/core/SPEC.md + one behavior contract, two engines + | + +---------------------+---------------------+ + | sync by spec, twin test suites | + v v ++---------------------------+ +---------------------------+ +| TS engine | | Rust engine | +| packages/core | | crates/core, crates/macros| +| @dunky.dev/state-machine | | dunky-core | ++---------------------------+ +---------------------------+ + | | + | authors | authors + v v ++---------------------------+ +---------------------------+ +| TS machines + connectors | | Rust machines + connectors| +| setup().config() | | derives + Config builder | +| e.g. palette, connect() | | e.g. dropdown, connect() | ++---------------------------+ +---------------------------+ + | | | | + | web: wasm32 | | native: lib | Rust app + | v v | + | +----------------+ +----------------+ | + | | crates/wasm | | crates/uniffi | | + | | export_machine!| | push protocol | | + | | wasm-bindgen | | ubrn turbo mod | | + | +----------------+ +----------------+ | + | | | | + | notify(state, fields), timers, field reads | + | v v | + | +----------------+ +----------------+ | + | | packages/wasm | | RN facade | | + | | fromWasm() | | (planned) | | + | | context mirror | | context mirror | | + | | no Rust engine | | no Rust engine | | + | +----------------+ +----------------+ | + | | | | + v v v v ++------------------------------------------------------------+ +------------------------+ +| Machine interface (TS, packages/core) | | Machine (Rust, | +| send, state, context, computed, subscribe, select, | | crates/core) | +| start/stop. toMachine(source) takes a config or any ready | | same contract, typed | +| machine; a target cannot tell which engine built it. | | fields, timers as cmds | ++------------------------------------------------------------+ +------------------------+ + | | + connector per machine connector per machine + | | ++------------------------------------------------------------+ +------------------------+ +| packages/shared/bindings | | crates/bindings | +| roles, keyboard, focus, a11y props; substrate-free | | (planned) the same | ++------------------------------------------------------------+ +------------------------+ + | | + +------------+---------+---------+------------+ | + v v v v v ++----------+ +----------+ +----------+ +----------+ +------------------+ +| react | | solid | | native | | opentui | | iced (planned) | +| web + RN | | web | | RN props | | terminal | | macOS, Windows, | ++----------+ +----------+ +----------+ +----------+ | Linux: winit + | + | AccessKit; OS | + | conventions in a | + | platform switch | + +------------------+ ``` -The two engines are independent: each is tuned for its own runtime. They stay -in sync through the spec, twin test suites, and one rule: a behavior change -updates the spec, both engines and both test suites in the same PR (see -[AGENTS.md](AGENTS.md#two-engines-one-spec)). The sandbox machines have Rust -twins too (`crates/sandbox`). - -A machine written in Rust can run in JS too: `export_machine!` (`crates/wasm`) makes -it a JS class in its own wasm module, and `fromWasm` (`@dunky.dev/state-machine-wasm`) -puts it behind the same `Machine` interface, so targets take it like a config -(`useMachine` accepts a ready machine). It still runs on the Rust engine; its TS types -are generated from its Rust types. +How to read it: + +- **Two engines, one spec.** `packages/core` and `crates/core` each implement + `packages/core/SPEC.md`. Neither runs on the other: each is tuned for its own + runtime. They stay in sync through the spec, twin test suites (`packages/core/tests` + and `crates/core/tests`, file by file) and one rule: a behavior change updates the + spec, both engines and both suites in the same PR (see + [AGENTS.md](AGENTS.md#two-engines-one-spec)). +- **A machine is authored against one engine, with its connector next to it.** TS + machines against the TS engine; Rust machines against the Rust engine, which is the + only place they run. The sandbox palette exists in both (`sandbox/shared`, + `crates/sandbox`), as a worked example of the twin rule. +- **The TS engine is the default in every JS runtime**, React Native included. A + machine is written in Rust when it is wanted in Rust too (a Rust app) or shared + across the web, React Native and Rust from one source. +- **The facades put a Rust machine behind the TS `Machine` interface.** They ship no + engine: they map names to numbers, keep a JS mirror of the context, re-read only the + fields each notify names, and fan changes out to subscribers. `packages/wasm` + (`fromWasm`) does it over `crates/wasm`; the React Native facade does the same over + `crates/uniffi`, which speaks the same push protocol (one `notify` per change, timers + as host calls). +- **One binding per substrate.** `crates/wasm` for JS runtimes with wasm (web, Node, + Bun, so OpenTUI too). `crates/uniffi` for React Native over JSI, generated into a + turbo module by uniffi-bindgen-react-native (`sandbox/uniffi`). +- **Targets are engine-blind.** They take a `Machine` through `toMachine` and + `connect`, so react, solid, native and opentui work unchanged whichever engine built + it. +- **Rust renders too.** A Rust app holds the `Machine` directly, no binding and no + facade. Above it the Rust column mirrors the TS one: a connector per machine, one + shared bindings crate (the twin of `packages/shared/bindings`), and one target, iced. + iced already spans macOS, Windows and Linux (winit for windows and input, AccessKit + for the accessibility tree), so the OS differences that remain, such as Cmd vs Ctrl + or Home/End conventions, are a platform switch inside the target, not a crate per OS. ## The machine parts diff --git a/TODO.md b/TODO.md index d104212..85dcb57 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,11 @@ ran on the Rust engine, is on the local branch `rust-engine-under-ts` (head `4e4 ## Rust machines on React Native (JSI) -Spiked on 2026-10-09: `crates/uniffi` runs in the Expo sandbox through +Decided on 2026-10-09. The TS engine stays the default on React Native. A machine +written in Rust (e.g. a dropdown) is consumed as wasm on the web and over uniffi on React +Native, behind a facade that gives it the TS `Machine` interface, as `fromWasm` does. + +Spiked in PR #76: `crates/uniffi` runs in the Expo sandbox through uniffi-bindgen-react-native (`sandbox/uniffi`, a turbo module Expo autolinks into a dev build), in push mode (`attach(host)`: a `notify(state, lo, hi)` per change and timers as host calls, as `crates/wasm` speaks) next to the pull mode. The sandbox app times both @@ -22,15 +26,30 @@ simulator, per event: `Arc` clone, then the call) plus the status object and closures the generated TS allocates. Push and pull cost the same; a notify back into JS adds ~0.3 µs. - A JSON payload adds ~5 µs: the string is lowered, then parsed by serde. -- So uniffi does not get close to wasm, where a Rust machine runs within ~1.3× of the TS - engine. On React Native the TS engine stays the default: 315 ns per event is far below - any frame budget. - -- [ ] If Rust on React Native is still wanted: a hand-written JSI host object over the - handle's protocol (no per-call clone, no status allocation, typed payloads), or - ubrn's `jsi2` flavor — neither measured. -- [ ] Only then, the TS facade over the handle (what `fromWasm` is to a wasm class), so - `connect()`, the bindings and `packages/native` work unchanged. +- Fine for a UI machine: ~11,000 payload-less events fit in one 16.67 ms frame, and a + dropdown sends a handful. Too slow for a hot path (many machines updated per frame, + or field reads on every render): the facade must keep the hot path on the JS side. + +- [ ] The RN facade over `crates/uniffi` (`fromUniffi`, the twin of `fromWasm`): push + mode, a JS context mirror re-read only on the fields a notify names, renders read + the mirror. Reuse `RustMachine` from `packages/wasm`; only the handle differs. +- [ ] Typed payloads instead of JSON strings: uniffi records and enums as arguments + (`highlight(index: u32)`, `move(to: MoveTo)`); numbers and enums lift cheaply. +- [ ] Push mode for `start` / `stop` / `fire_timer` (today only `send` runs the timers + through the host). +- [ ] Only if the per-call floor ever matters: a hand-written JSI host object over the + handle's protocol, or ubrn's `jsi2` flavor. Neither measured. + +## Rust renders too + +The Rust column of the architecture diagram (`ARCHITECTURE.md`), above the engine: + +- [ ] A connector per Rust machine, next to it (`connect_palette`, the twin of + `connectCommandPalette`), following the TS connector's spec. +- [ ] `crates/bindings`: one shared crate, the twin of `packages/shared/bindings` + (roles, keyboard patterns, focus, a11y props), used by every Rust target. +- [ ] An iced target: one crate for macOS, Windows and Linux (winit + AccessKit); the OS + conventions that remain are a platform switch inside it. ## Rust machines in JS (wasm) — done, notes diff --git a/website/src/content/docs/rust/index.mdx b/website/src/content/docs/rust/index.mdx index f6e2924..f8fdd92 100644 --- a/website/src/content/docs/rust/index.mdx +++ b/website/src/content/docs/rust/index.mdx @@ -27,17 +27,17 @@ JS and every target Rust programs, native bindings, - **Building in Rust** (a native app, a game, a server): write them in **Rust**, with `dunky-core`. The same states, events, guards, effects, timers and computed values. - **Sharing one machine between Rust and JS**: write it in Rust, and use it from - TypeScript through wasm with [`fromWasm`](/rust/typescript). The machine still runs - on the Rust engine. + TypeScript through wasm with [`fromWasm`](/rust/typescript) on the web, or over + uniffi on React Native (in progress). The machine still runs on the Rust engine. ## The crates -| Crate | What it is | -| -------------- | ------------------------------------------------------------------------- | -| `dunky-core` | The Rust engine: machines, config builder, timers as commands, observers. | -| `dunky-macros` | `#[derive(State, Event, Context)]`, re-exported by `dunky-core`. | -| `dunky-wasm` | `export_machine!`: a Rust machine as a JS class, in your own wasm module. | -| `dunky-uniffi` | React Native over JSI (in progress). | +| Crate | What it is | +| -------------- | --------------------------------------------------------------------------- | +| `dunky-core` | The Rust engine: machines, config builder, timers as commands, observers. | +| `dunky-macros` | `#[derive(State, Event, Context)]`, re-exported by `dunky-core`. | +| `dunky-wasm` | `export_machine!`: a Rust machine as a JS class, in your own wasm module. | +| `dunky-uniffi` | Rust machines on React Native, a turbo module through uniffi (in progress). | The crates are not on crates.io yet. Use them from the repository: From 615dfc8a531f961433c2b1eec1d8034d7136973f Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 9 Oct 2026 17:48:16 +0200 Subject: [PATCH 3/3] docs(readme): a universal state machine, two engines on one spec The intro says what the Rust side changes: the machine is substrate-agnostic and language-agnostic, with a TS engine and a Rust engine on one spec, and a machine written in Rust reaching TypeScript through wasm behind the same Machine interface. A compact diagram, a pointer to the Rust benchmark, and links to the Rust docs. Co-Authored-By: Claude Fable 5.1 --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c9ee144..4efef44 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,38 @@ # STATE-MACHINE -**Define a behavior once. Render it anywhere.** +**Define a behavior once. Render it anywhere. Write it in TypeScript or Rust.** A UI component is really two things tangled together: _behavior_ and _render_. -Dunky splits them. You describe behavior as a plain TypeScript -**state machine** that knows nothing about the environment and a thin per-substrate -layer plugs it into a runtime. - -The same machine drives any render in a JS runtime. Same states, same -transitions, same accessibility intent. Only the render differs. +Dunky splits them. You describe behavior as a plain **state machine** that knows +nothing about the environment, and a thin per-substrate layer plugs it into a runtime. + +The machine is universal in two ways. It is **substrate-agnostic**: the same machine +drives the DOM, React Native, a terminal, any render in a JS runtime. And it is +**language-agnostic**: the engine exists twice, in TypeScript (`packages/core`) and in +Rust (`crates/core`), both implementing one spec. Write a machine in TypeScript and it +runs in every JS runtime. Write it in Rust and it runs in Rust programs, and in +TypeScript too: compiled to wasm, it sits behind the same `Machine` interface, so +every target takes it like any other machine. + +``` + packages/core/SPEC.md + one contract, two engines + | + +-----------------+-----------------+ + v v + TS engine (packages/core) Rust engine (crates/core) + machines in TypeScript machines in Rust + | | + | +---------+---------+ + | | | + | wasm, uniffi Rust apps + | v (iced, planned) + | fromWasm: the TS Machine interface + | over a Rust machine (packages/wasm) + v v + react . solid . native . opentui: targets never know which engine +``` ## The challenge @@ -30,6 +53,12 @@ platform — a keydown listener, a timer, a focus — is pushed to the per-targe layer (the view's `effects.ts`). So the _same_ machine runs unchanged on the DOM, React Native, or any other JS runtime. +The same idea applies to the language. Neither engine runs on the other: the TS +engine is pure TypeScript, tuned for JS runtimes; the Rust engine is tuned for Rust, +with no JS trade-off. What keeps them one product is the spec and twin test suites, +not a shared runtime. See **[Two engines, one spec](./ARCHITECTURE.md#two-engines-one-spec)** +for the full map, including the Rust render path. + ### Fast at scale The hard case is **many machines reacting to many events inside @@ -49,6 +78,10 @@ engine is built for it: **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. +The Rust engine, measured natively, handles ~96 M events/s with zero allocations in the +steady state: no workload in its suite uses more than 3% of a 60 fps frame. See the +**[Rust benchmark](./benchmark/rust/README.md)**. + ## How it's built The machine's behavior flows out through a few thin layers until it reaches real @@ -57,9 +90,11 @@ elements — the left two are agnostic, the right three are per-target: ![How Dunky works, from agnostic to substrate: the dunky engine powers, the machine decides, the binding connects, the behavior appears](./website/src/assets/diagrams/flow-animated.svg) - **core** — the state-machine engine. Pure behavior: states, transitions, - context, effects. Knows nothing about a renderer. The same machines also - exist in Rust ([`crates/core`](./crates/core)): a second engine that - follows the same spec, for Rust programs and native bindings. + context, effects. Knows nothing about a renderer. It exists in TypeScript + ([`packages/core`](./packages/core)) and in Rust ([`crates/core`](./crates/core)), + two engines on one spec. A machine written in Rust reaches TypeScript through + wasm ([`crates/wasm`](./crates/wasm) + [`packages/wasm`](./packages/wasm)) and + React Native through uniffi ([`crates/uniffi`](./crates/uniffi), in progress). - **connector** — turns machine state into agnostic _bindings_ and keeps that view in sync as the machine changes. - **normalize** — per target, translates those bindings into real props @@ -72,7 +107,8 @@ elements — the left two are agnostic, the right three are per-target: The full layered model and the "the machine never sees props" rule are in: - **[`ARCHITECTURE.md`](./ARCHITECTURE.md)** — the big-picture map and the layered model. -- **[`packages/core/README.md`](./packages/core/README.md)** — the state machine engine and its full API. +- **[`packages/core/README.md`](./packages/core/README.md)** — the TS engine and its full API. +- **[`crates/core/SPEC.md`](./crates/core/SPEC.md)** and the **[Rust docs](https://dunky.dev/state-machine/rust)** — the Rust engine, machines in Rust, and using them from TypeScript. - **[`benchmark/`](./benchmark/README.md)** — the TS suite (methodology, results vs. XState & Zag) and the Rust suite. - **[`ACCESSIBILITY.md`](./ACCESSIBILITY.md)** — the external specs every package answers to. - **[`AGENTS.md`](./AGENTS.md)** — the contributor / agent contract.