Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
681521b
feat(core): toMachine, makeBroadcast and makeSelection exports
ivanbanov Oct 5, 2026
9e1bd08
feat(react,solid): useMachine accepts a ready machine
ivanbanov Oct 5, 2026
2ab995e
feat(rust): port the engine to Rust, with a wasm binding
ivanbanov Oct 5, 2026
7fb379a
feat(wasm): TS adapter for Rust machines and the demo package
ivanbanov Oct 5, 2026
29f7dcf
feat(benchmark): TS core vs Rust core (wasm)
ivanbanov Oct 5, 2026
ca1655c
feat(sandbox): run the palette on the Rust engine (React, Solid, open…
ivanbanov Oct 5, 2026
60306fe
feat(native): React Native on the Rust engine over JSI
ivanbanov Oct 5, 2026
6c79c0d
ci: Rust checks, wasm build, workspace wiring and docs
ivanbanov Oct 5, 2026
4a59d37
feat(rust): host binding API in the engine
ivanbanov Oct 7, 2026
c0825d3
feat(core): run TS machines on the Rust engine, Rust machines via fro…
ivanbanov Oct 7, 2026
caa4383
docs: the Rust engine, both machine languages, package SPECs
ivanbanov Oct 7, 2026
3c710ca
perf(benchmark): TS vs Rust machines, engine heap in memory, new numbers
ivanbanov Oct 7, 2026
3424d99
perf: one engine call per transition for TS machines
ivanbanov Oct 7, 2026
e787306
docs: one vocabulary for the repo, the TS core and the Rust engine
ivanbanov Oct 7, 2026
8afdd73
perf(benchmark): numbers after the churn work
ivanbanov Oct 7, 2026
f766dd9
perf: computed values of TS machines stay in JS
ivanbanov Oct 7, 2026
f985581
perf(benchmark): numbers after the computed fix
ivanbanov Oct 7, 2026
9cf543c
chore(core): clippy clean for dunky-core on its own
ivanbanov Oct 7, 2026
134cd87
feat: a Rust machine's TS types come from its Rust types
ivanbanov Oct 7, 2026
f1621a8
perf: no facade reference crosses into wasm
ivanbanov Oct 7, 2026
4e492a4
perf(benchmark): numbers with no facade crossing
ivanbanov Oct 7, 2026
8b1e81b
refactor: two engines, one spec
ivanbanov Oct 8, 2026
000ff4a
feat: Rust machines in TypeScript, through wasm
ivanbanov Oct 8, 2026
129ec70
docs(website): the Rust engine — home section and a Rust docs group
ivanbanov Oct 8, 2026
93be886
perf(benchmark): one suite per engine, benchmark/ts and benchmark/rust
ivanbanov Oct 8, 2026
dbd38d4
perf(rust): no allocation in the steady state, and a 60 fps frame budget
ivanbanov Oct 8, 2026
610eb34
perf(rust): field selections, leaner notifies, and why TS stays off wasm
ivanbanov Oct 8, 2026
ef3da07
docs: why two engines, not Rust everywhere
ivanbanov Oct 8, 2026
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
3 changes: 3 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[alias]
# `cargo lint`: clippy over the whole workspace, warnings as errors (as CI runs it).
lint = "clippy --workspace --all-targets -- -D warnings"
22 changes: 22 additions & 0 deletions .changeset/state-machine-wasm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@dunky.dev/state-machine-wasm': minor
---

New package: use a machine written in Rust from TypeScript. `fromWasm` wraps a class
exported with `dunky_wasm::export_machine!` in the same `Machine` interface the TS
engine returns, so `connect()`, `useMachine` and every target take it unchanged, next
to TS machines.

```ts
import { fromWasm } from '@dunky.dev/state-machine-wasm'
import init, { PaletteMachine } from './pkg/my_machines.js' // your wasm-bindgen output

await init()
const palette = fromWasm(new PaletteMachine(commands)) // typed from the Rust types
```

The machine runs entirely in Rust, on the Rust engine (`dunky-core`), which follows the
same spec as the TS engine. Its TS types are generated from its Rust types and inferred
by `fromWasm`, so they are never written twice. In JS a TS machine is a little faster,
because its context already lives in JS: write a machine in Rust to reuse it in Rust
programs and on React Native.
29 changes: 29 additions & 0 deletions .changeset/use-machine-accepts-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@dunky.dev/state-machine': minor
'@dunky.dev/react-state-machine': minor
'@dunky.dev/solid-state-machine': minor
---

`useMachine` now accepts a ready machine as well as a config. The first argument
may return either, so a machine built elsewhere — for example a machine written
in Rust and wrapped with `fromWasm` — plugs into the same hook, connector and
component effects:

```ts
// a config (unchanged)
useMachine(commandPaletteMachineConfig, connect, effects, props)

// a ready machine, e.g. one written in Rust (fromWasm: @dunky.dev/state-machine-wasm)
useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props)
```

A ready machine must come fresh from the factory (one per component instance): the
hook owns its lifecycle, as it does for a config-built machine.

Core exports the helper both bridges use, `toMachine(source)`, and its
`MachineSource` type: a config is built into a stopped service, a ready
machine is returned as is.

Core also exports `makeBroadcast` and `makeSelection`, the building blocks of its
subscriptions, so a machine that runs outside this engine (such as a Rust machine)
notifies and dedupes exactly like a TS one.
17 changes: 12 additions & 5 deletions .claude/skills/benchmark/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ explicit yes — benchmark numbers are noisy and the user may just want a look.

## Step 1 — run it

The suite is its own workspace package under `benchmark/`, but the root has a
delegating script, so this works from the repo root or from `benchmark/`:
The TS suite is its own workspace package under `benchmark/ts/`, but the root has a
delegating script, so this works from the repo root or from `benchmark/ts/`:

```bash
pnpm benchmark
Expand All @@ -41,7 +41,7 @@ rendering 1000-row mount + re-render).
After showing the numbers, ask the user — use the AskUserQuestion tool — whether
to update the documented results. Offer at least:

- **Update all docs** — refresh numbers in the benchmark README, the core package
- **Update all docs** — refresh numbers in the TS benchmark README, the core package
README, and the website benchmark page.
- **Don't update** — just keep the run output.

Expand All @@ -51,7 +51,7 @@ Do not proceed to Step 3 unless the user says yes.

Four files carry benchmark numbers. Update all four in one pass:

### A. `benchmark/README.md` — full tables (source of truth)
### A. `benchmark/ts/README.md` — full tables (source of truth)

Each `## N.` section has its table inline, followed by a one-line `→` takeaway.
Update the numbers in those tables and **recheck each takeaway's "X faster" claim**
Expand Down Expand Up @@ -100,7 +100,7 @@ The website benchmark page (at that path from the repo root) carries:
the numbers and **recheck the section heading ratios** against the fresh figures
(e.g. if throughput is now 6.1 M vs 897 K, the heading becomes "~7×", not "~8×").

Apply the same K/M notation rules as `benchmark/README.md`.
Apply the same K/M notation rules as `benchmark/ts/README.md`.

### D. root `README.md` — the "Fast at scale" headline table

Expand All @@ -115,3 +115,10 @@ notation rules.
- Run `pnpm format` at the repo root so formatting matches the repo style.
- Remind the user these are first-look numbers from one machine — a single run is
a snapshot, not a verdict.

## The Rust suite

The Rust engine has its own suite in `benchmark/rust` (`cargo bench -p dunky-benchmark`).
When the user asks for Rust numbers, run it the same way: show the tables first, then
ask before updating `benchmark/rust/README.md`. Its "TS engine" column is copied from
`benchmark/ts/README.md`, so refresh it when the TS tables change.
36 changes: 36 additions & 0 deletions .github/actions/wasm-setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Setup wasm
description: Build the wasm of the sandbox machines written in Rust (pnpm build:wasm) for jobs that import them. Runs after ci-setup.

runs:
using: composite
steps:
- name: setup rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
# Every job that uses this action builds the same wasm: share one cache.
shared-key: wasm

- name: read wasm-bindgen version
id: wasm-bindgen
shell: bash
# The CLI must match the wasm-bindgen crate exactly. Read the version from
# Cargo.lock instead of pinning it a second time here.
run: |
version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p')
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: setup wasm-bindgen
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen@${{ steps.wasm-bindgen.outputs.version }}

# wasm-opt (binaryen) isn't installed, so the script skips its size pass: the
# binary is bigger but behaves the same.
- name: build wasm
shell: bash
run: pnpm build:wasm
30 changes: 30 additions & 0 deletions .github/workflows/ci-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/ci-setup
# The sandbox machines written in Rust (crates/sandbox -> wasm).
- uses: ./.github/actions/wasm-setup
- run: pnpm test:ci

typecheck:
Expand All @@ -41,6 +43,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/ci-setup
# The sandbox machines written in Rust (crates/sandbox -> wasm).
- uses: ./.github/actions/wasm-setup
- run: pnpm typecheck

lint:
Expand All @@ -59,6 +63,32 @@ jobs:
- uses: ./.github/actions/ci-setup
- run: pnpm format:check

rust:
# The Rust engine (crates/core): its own implementation of packages/core/SPEC.md.
# The Cargo workspace needs no Node, so no ci-setup.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
crates/uniffi -> target
- run: cargo fmt --all --check
- run: cargo lint
- run: cargo test --workspace
# Again in release: overflow checks and debug assertions are off there.
- run: cargo test --workspace --release
# crates/uniffi (React Native over JSI) sits outside the workspace with its own
# lockfile; its checks run on the host and need no mobile toolchain.
- run: cargo fmt --manifest-path crates/uniffi/Cargo.toml --check
- run: cargo clippy --manifest-path crates/uniffi/Cargo.toml --all-targets -- -D warnings
- run: cargo test --manifest-path crates/uniffi/Cargo.toml

versioning:
# The release PR consumes changesets rather than adding one — never flag it.
if: github.head_ref != 'changeset-release/main'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/deploy-benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ jobs:
# the same path, so the base must match.
env:
BASE_PATH: /state-machine/benchmark/demo/
run: pnpm -C benchmark/demo build
run: pnpm -C benchmark/ts/demo build

- uses: actions/configure-pages@v5

- uses: actions/upload-pages-artifact@v3
with:
path: benchmark/demo/dist
path: benchmark/ts/demo/dist

deploy:
needs: build
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ dist

# local-only Claude settings (never committed; keep it out of format/lint too)
.claude/settings.local.json

# Rust
/target
# wasm-bindgen output (built by `pnpm build:wasm`)
crates/sandbox/pkg

# Expo prebuild output (`expo prebuild` / `expo run:*` in the RN sandbox)
sandbox/native/ios
sandbox/native/android
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@
}
},
"globals": {},
"ignorePatterns": ["**/dist/**", "website/src/snippets/**"]
"ignorePatterns": ["**/dist/**", "website/src/snippets/**", "crates/sandbox/pkg/**"]
}
36 changes: 29 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ This is Dunky's state-machine monorepo: UI behavior authored once as
plain TypeScript state machines (`packages/core`), rendered anywhere
through thin per-substrate targets (`react`, `solid`, `native`, `opentui`), with
a benchmark suite, per-substrate sandboxes, and the docs website
alongside.
alongside. The same machines also exist in Rust (`crates/core`): a second
engine that follows the same spec, for Rust programs and native bindings.

## Preflight

Expand All @@ -24,12 +25,13 @@ editing files in that scope — it overrides anything here for that scope

## Scopes

| Scope | Path | What it is |
| --------- | ------------- | ----------------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals |
| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries |
| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification |
| Website | `website/` | The docs site |
| Scope | Path | What it is |
| --------- | ------------- | -------------------------------------------------------------------------------------------------------------------- |
| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals |
| Benchmark | `benchmark/` | Perf suites: `ts/` (the TS engine against competitor libraries) and `rust/` (the Rust engine) |
| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification |
| Website | `website/` | The docs site |
| Crates | `crates/**` | The Rust engine (`core`, `macros`), its JS binding (`wasm`), the sandbox machines in Rust, the RN binding (`uniffi`) |

Some changes are cross-scope: a change in `core/` may need follow-up in
the targets, sandboxes, and docs — and vice versa. Check what else your
Expand All @@ -54,6 +56,26 @@ layered model:
Props-free, platform-free effects belong in the core machine config's
`effects` (authored via `setup()`).

## Two engines, one spec

The machine behavior is implemented twice: in TypeScript (`packages/core`)
and in Rust (`crates/core`). Neither runs on the other: each is optimized
for its own runtime, with no trade-off made for the other.
`packages/core/SPEC.md` is the one behavior contract both follow.

- **A behavior change is one PR for both.** Update `packages/core/SPEC.md`,
the TS engine, the Rust engine, and both test suites together. A change
that lands in one engine only is not done.
- **Tests are twins.** `crates/core/tests` is ported from
`packages/core/tests`, file by file: a new behavior test on one side gets
its twin on the other.
- **Check parity before shipping.** Read the diff of each engine against
the spec, and say in the PR which behavior changed and where each engine
handles it.
- **Engine-only things stay out of the shared spec.** TS-only typing
(`setup()`) and Rust-only authoring (derives, timers as commands) live in
their own docs: `packages/core/README.md` and `crates/core/SPEC.md`.

## Workflow

`SPEC -> TEST -> IMPLEMENT -> RECONCILE`
Expand Down
32 changes: 32 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ Zag, whose machines read props directly.)
| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) |
| `packages/shared/utils/` | mergeProps, composeHandlers |
| `packages/<target>/` | Hook + normalize per substrate (react, solid, native, opentui, …) |
| `crates/core/` | The Rust engine: the same spec, in Rust (`dunky-core`) |
| `crates/macros/` | `#[derive(State, Event, Context)]` for Rust machines |
| `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) |

## The map

Expand Down Expand Up @@ -138,6 +144,32 @@ Three package groups, three jobs:
- **`<target>/`** — _the substrate side_. One folder per renderer
(`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator.

## Two engines, one spec

```
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
```

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.

## The machine parts

`machine.ts` is the state graph (states, transitions, action impls). It
Expand Down
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ pnpm install

Target one package or one file instead of the whole workspace:

### Rust

The Rust engine (`crates/`) is a plain Cargo workspace; run cargo at the repo root:

| Command | What it does |
| -------------------------------- | --------------------------------------------------- |
| `cargo test` | Every crate's tests |
| `cargo lint` | `clippy`, warnings as errors (`.cargo/config.toml`) |
| `cargo fmt` / `--check` | `rustfmt` |
| `cargo bench -p dunky-benchmark` | The Rust engine's benchmark (`benchmark/rust`) |

`pnpm build:wasm` builds the sandbox machines written in Rust to wasm (`crates/sandbox/pkg`),
with their TS types: the sandbox's `?machine=rust` mode and its Rust tests need it. It
needs the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` at the version
`Cargo.toml` pins.

## Sandbox

Each sandbox renders the same command-palette machine on a different
Expand Down
Loading
Loading