Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 100 additions & 21 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<T> (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<T>` 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

Expand Down
58 changes: 47 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
52 changes: 45 additions & 7 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,51 @@ 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.
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
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.
- 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

Expand Down
7 changes: 5 additions & 2 deletions crates/uniffi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading