Repository navigation
Conversation
- toMachine(source) builds a stopped service from a config and returns a ready machine as is; MachineSource is the type both bridges accept. - makeBroadcast and makeSelection are exported so other Machine implementations (the wasm adapter) share the same notify and value-dedup semantics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first argument may return a config (TS engine) or a ready machine, e.g. a Rust machine compiled to wasm. A ready machine must come fresh from the factory: the hook owns its lifecycle, as for a config-built one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- crates/core (dunky-core): the engine of packages/core in Rust, same SPEC. Typed machines (#[derive(State, Event, Context)] in crates/macros), sans-IO `after` timers (the host runs timer commands), a change mask per call, and the handle protocol every binding shares (dunky_core::protocol). The tests port the 16 TS test files, plus Rust-only cases. - crates/demo: the sandbox command palette, a dialog with an exit timer, and the benchmark machines, written against dunky-core. - crates/wasm (dunky-wasm): export_machine! turns a machine type into a JS class (sendKind / sendPayload / change mask / timer commands). - crates/demo-wasm + scripts/build-wasm.sh: the demo machines built to packages/demo-wasm/pkg (wasm-bindgen --target web, wasm-opt -Oz). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- @dunky.dev/state-machine-wasm: fromWasm(handle) returns the TS Machine interface over a Rust machine. It mirrors context into one plain object, re-reads only the fields the change mask names, caches computed values by version, and runs timer commands with setTimeout. Observers are notified once per call. - @dunky.dev/demo-wasm (private): the Rust demo machines as TS Machines. Parity tests run the Rust palette next to the TS palette step by step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- tests/wasm.ts: the same machines on both engines — one event, state churn, guard fallthrough, palette typing at 11 / 1,000 / 10,000 commands, construction, and the raw boundary cost. SPIKE_OUT=<file> writes JSON. - The rendering bench gains wasm/selector and wasm/instance rows. - Both are skipped with a warning when the wasm is not built, so the TS benchmarks still run without a Rust toolchain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tui) The React and Solid sandboxes take ?engine=rust, opentui takes --engine=rust (Bun runs the wasm). Same connect(), same markup; only the machine source differs (CommandPaletteSource). The wasm is imported only for the Rust engine, so the TS pages run without the wasm build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hermes has no WebAssembly, so React Native runs the same Rust crate as native code: crates/uniffi exports the palette machine with uniffi, and uniffi-bindgen-react-native builds it into a Turbo Module (packages/native-rust: xcframework for iOS, jniLibs for Android). NativePaletteHandle speaks the same handle protocol as the wasm binding, so fromWasm wraps it unchanged. The Expo sandbox gains an engine toggle; without the native build it falls back to the TS palette. Also: sandbox/native turns off Expo's tsconfig paths experiment (Expo 54 reads them through the TypeScript JS API, which TypeScript 7 does not have) and dedupes react-native in Metro. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CI: a rust job (fmt, clippy with warnings as errors, tests in debug and release, and the same for crates/uniffi); the test and typecheck jobs build the wasm first (.github/actions/wasm-setup reads the wasm-bindgen version from Cargo.lock). - Scripts: build:wasm, test:rust, lint:rust. - Workspace: tsconfig paths and build references, tsdown entry for @dunky.dev/state-machine-wasm, knip ignore for the generated bindings. - Docs: Rust setup in CONTRIBUTING, the Rust core in ARCHITECTURE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine can now run a machine whose user code lives in another runtime (cargo feature `host`): guards, actions, effects, delays and computed values referenced by number, run by a `Host`, which also hears every change. - A failing callback raises the machine's halt flag; the step stops at the next checkpoint, what is queued stays queued, and the binding rethrows. - A run of host actions is one call, and so is a guard walk over a handler's candidates (`Action::ExternalList`, entry pick lists). - A host may hold a state notification back and deliver it with its next callback, no later than `Host::settle` at the end of the transition. - A host that owns the context reports its writes (`mark_changed`); the engine stamps them and runs the watchers, the host notifies. Lighter machines for every binding: per-field stamps sized to the fields a machine writes, broadcasts created on first subscribe, no effect or watcher work for states and machines that have none, and an idle `send` that skips the queue. SPEC.md (new) covers what is specific to the crate: authoring in Rust, timers as commands, change reporting and the binding API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mWasm @dunky.dev/state-machine becomes a thin TS layer over the Rust engine, compiled to wasm (crates/core-wasm) and inlined, so it starts synchronously on the first machine() call. A TS config compiles once into numbers and JS tables; Rust runs the graph and calls back into the user code. The 16 core test files pass unchanged on it. One protocol (crates/wasm) for both kinds of machine: typed host calls that return status codes, the facade passed with each call (Rust holds no JS object between calls), a notify per change, timers on the host clock. JS owns the errors. A TS machine announces its own context writes; Rust hears about a write only when watchers or computed values need it, and a cached computed value is served without calling Rust. Machines written in Rust export with dunky_wasm::export_machine! and wrap with fromWasm, now exported by the core package. The adapter package (packages/wasm) and its exports for other engines (makeBroadcast, makeSelection) are gone. Restructure: - packages/demo-wasm and crates/demo* removed: the palette and the dialog move to sandbox/shared/rust (with the real-wasm tests), the benchmark machines to benchmark/rust. - packages/native-rust removed until the React Native engine over JSI lands; the native sandbox is back to the TS palette. - Sandboxes switch with ?machine=rust (--machine=rust in the terminal). - CI builds the wasm for build, test, typecheck, release and the benchmark demo; wasm-bindgen runs with reference types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AGENTS.md: the repo now runs one Rust engine for machines written in TS or Rust; crates/ is a scope; core is TypeScript over the engine. - ARCHITECTURE.md: the engine section (protocol, one facade, few crossings) and the new project map. - packages/core/SPEC.md: the engine, sources (config or ready machine), machine lifetime, machines written in Rust, writes only through setContext, the runaway guard in production too. README: a quick reference for Rust machines. - packages/react/SPEC.md, packages/solid/SPEC.md (new): the lifecycle hook, the source it accepts, selection, component effects, edge cases. - CONTRIBUTING.md, sandbox/README.md: the Rust toolchain is now required; ?machine=rust. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The memory section counts the engine's wasm heap for core (a JS heap sample does not see it; the shipped wasm counts its live bytes). - benchmark/README.md section 8, and the website: the same machine written in TS and in Rust. In JS a TS machine is faster (its context lives in JS); Rust machines are for reuse across hosts. - Refreshed tables (average of two runs) in the benchmark README, the website page and the root README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
State and effect churn paid one call into JS per step. Now: - A leaving transition of a TS machine without computed values runs whole in one host call: effect cleanups, exit actions, the transition's actions, the switch and its notification, entry actions, the target's effects (`Host::transition`, enabled per transition by a host id). The engine switches state after the call; a failure before the switch leaves the machine where it was. - The host keeps the cleanups of the effects it starts; the engine signals one effects stop (`Host::stop_effects`), which rides on the next callback or is settled before the state switch. No boxed cleanup per effect, and the engine's cleanup list keeps its buffer. - Leaner steps: an unguarded first candidate skips guard setup, one borrow around the host transition, no settle after it, and the engine-failure slot is read only when the halt flag is up. Benchmark (tight loop): state churn 7.7 -> 8.2 M/s with the call removed and up to 9.2 M/s in the suite; effect churn 4.4 -> 5.8 M/s in the suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ARCHITECTURE.md now defines every piece in one place: the layers (engine, engine binding, core, shared, target), how a machine written in TS and one written in Rust run on the engine, the facade and the protocol between them, and a vocabulary grouped by the repo, the TS core and the Rust engine. "host" now means only the engine's callback side (the old "host = the agnostic core" meaning is retired), and "engine binding" names crates/wasm and crates/uniffi apart from the shared/bindings vocabulary. The engine and wasm SPECs describe the final protocol: host-kept effect cleanups, the effects stop, whole transitions, and the `pre` channel. The root README lists the engine and the core, with links to both SPECs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Average of two runs on the final engine: one event 9.7 M ops/s (~6× XState), state churn 7.5 M, effect churn 6.7 M, cached computed read 56.3 M, construction 1.38 µs, memory 4.08 KB at 64 fields (~33× lighter than Zag). Section 8 (TS vs Rust machines) and the website follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
A computed read after an unrelated write made two JS<->wasm crossings, and a recompute made six. The old TS core made none. Most of them are gone: - JS caches each computed value with what its last evaluation read. A write clears only the values that read a written field (and their readers), so an untouched value is served with no call. - The value never crosses: the engine gets a status that says whether it changed (JS runs Object.is), so no Object.is import call, no value copy. - What an evaluation read crosses only when it differs from last time. - Without watchers, a write report is a plain call (stamp), and only for fields a computed value read; the engine runs no code for it. The engine's Host::computed now returns an Evaluation (changed + reads) instead of a value; computed_external no longer takes an equality. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Average of two runs. The computed rows the fix targets: - recompute 3.4 M -> 4.5 M (old TS core: 4.9 M) - re-read after an unrelated write 5.7 M -> 8.3 M (old: 10.7 M) - 4-deep chain 982 K -> 2.3 M (old: 1.5 M) Memory per machine drops ~0.1 KB: machines without computed values no longer allocate cache arrays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without the host feature the computed match has one arm; the workspace build hid it because dunky-wasm turns the feature on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TS types of a Rust machine were written by hand (fromWasm<State, Context, Event, Computed>), so they could drift from the Rust ones. Now they are generated: - dunky-core: a TsType trait. #[derive(State, Event, Context)] write the TS types of states, events and context; #[derive(TsType)] covers the data types they hold. Names and shapes follow the serde attributes the values cross with (None is null, skipped-when-None is optional). - dunky-wasm: export_machine! gives each class typescript(&config), and an optional `internal` block for computed values it serves but leaves out of its types. - pnpm build:wasm appends them to the wasm-bindgen .d.ts as a __types member of the class; fromWasm infers Machine<...> from it (explicit generics still work for classes built without types). - The sandbox type-checks the Rust palette's generated types against the TS palette's, exactly, and sandbox/shared joins `pnpm typecheck`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call into the engine, and every host callback, passed the JS facade as a reference: each one went into the wasm reference table and out again. Rust only calls the host during a call into the machine, so JS now keeps the facade of the call in progress itself (saved and restored as calls nest), and the host functions run for it. Calls carry only numbers and the JS event. One event with an action: ~71 ns -> ~61 ns; a bare call into a machine: 29 ns -> 22 ns. The Rust-machine classes lose their optional facade argument; once attached, call them through their fromWasm facade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Average of two runs. One event 9.4 M -> 9.9 M; TS machine one event 9.8 M -> 10.7 M; Rust machine one event 6.6 M -> 8.5 M, and its guard walk now beats the TS twin (its guards never leave wasm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TS engine and the Rust engine become two implementations of packages/core/SPEC.md, each tuned for its own runtime. TS machines no longer run on the Rust engine. - Everything outside the Rust engine is back to main: packages/core is the pure TS engine again (no wasm), and the targets, sandbox, benchmark, website and CI are as on main. - crates/core drops what existed only to run TS machines: the `host` feature (Host trait, External parts, halt flag, whole transitions) and TsType. The general wins stay (sized field stamps, lazy broadcasts, the idle send fast path). - crates/wasm, crates/core-wasm and the Rust benchmark machines are gone for now; the full version is kept on the local branch rust-engine-under-ts. - All Rust lives in crates/: the sandbox machines move to crates/sandbox (crates/uniffi runs the palette on React Native). Rust commands are plain cargo; `cargo lint` is a .cargo/config.toml alias, used by CI. - AGENTS.md: a behavior change updates the spec, both engines and both test suites in the same PR; the test suites are twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A machine written in Rust now plugs into TS code next to the TS engine's machines, with no change to either engine: - crates/wasm (dunky-wasm) is back as Rust machines' JS binding only (export_machine!), without the TS runtime. It hears changes through the engine's own subscribe() / take_changes() and runs timers from take_commands(), so the engine keeps no JS hook. Freeing a class undoes its subscription, so the machine is freed with it. - @dunky.dev/state-machine-wasm (packages/wasm) holds fromWasm: the TS Machine interface over a wasm class, typed from the Rust types (TsType is back in dunky-core and the derives). - The TS core exports toMachine / MachineSource, and makeBroadcast / makeSelection so a Rust machine notifies and dedupes like a TS one; React and Solid useMachine accept a ready machine. - The sandbox runs the Rust palette with ?machine=rust again (built from crates/sandbox by pnpm build:wasm); a type test pins its types to the TS palette's, and sandbox/shared joins pnpm typecheck. - TODO.md keeps the parked work: the React Native facade over uniffi, and benchmarks split into benchmark/ts and benchmark/rust. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Home: a "Two engines, one spec" section: the same machine in TypeScript and in Rust, both following one contract, and Rust machines in JS through wasm. - Docs, new "Rust" group: Two engines (which to use, the crates, how they stay in sync), Machines in Rust (dunky-core: types, config, timers as commands, computed), and Use in TypeScript (export_machine!, generated types, fromWasm, useMachine). - Install: how to depend on dunky-core. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The TS suite (and its live demo) moves to benchmark/ts, unchanged; pnpm benchmark, the demo build and its deploy follow it. - benchmark/rust (dunky-benchmark) measures the Rust engine natively on the same scenarios: throughput, unobserved writes, 64 observers, computed, guards, state and effect churn, construction and memory. A plain `cargo bench -p dunky-benchmark` target, no extra dependency. - benchmark/README.md indexes both; the Rust README shows the TS engine's row next to each Rust row, for reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A selection reuses its stored value in place when nothing else holds it, instead of a fresh Rc per change; a computed value's definition evaluates into its slot, reusing the memory the same way (it also says whether the value changed, so the type-erased equality goes). One event with an observer: ~52 M -> ~93 M events/sec; recompute ~42 M -> ~68 M. - benchmark/rust gains a frame-budget section: six per-frame workloads (up to 10,000 machines with an event each) timed frame by frame over 600 frames, with allocations counted. The worst frame of the heaviest workload uses 2% of 16.67 ms, with zero allocations per frame. - The Rust README presents the Rust engine on its own (no TS column). - A Rust-only test: a computed value a reader still holds keeps its contents after a recompute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same behavior as the spec (and the TS twin tests); faster where it counts: - select_field(Field): the Rust twin of TS select.context(key). Field handles are now typed (Field<Ctx, V>, with a getter), so the TS test "select.context(key) selects one field" has its Rust twin at last. - A listener carries the change bits it can react to: a field selection wakes only on its field, select_state only on a state change. Others skip it with one bit test; order stays subscription order. A selection created mid-action wakes on everything until seeded, so it never misses a change (new Rust-only test, which fails without the rule). - One borrow per action instead of two, no second state read between process and apply, a counter instead of a RefCell borrow per listener in notify, and an idle send skips the empty flush. 64 observers, 1 change: 3.6 M -> 22.6 M/sec (6.3x); unobserved write over 5,000 cells: 69 M -> 98 M/sec. The frame budget's heaviest workload now peaks at 0.4% of a frame (it was 2%). Docs: the crate SPEC (typed fields, wake bits), the website's Rust pages, and a short note in the wasm READMEs and the website on why the TS engine does not run on Rust: tried, the JS<->wasm bridge made it slower. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reframe the note in the wasm READMEs and the website: one Rust engine behind the TS package worked, but part of the TS side still mirrored the Rust engine and every event paid the bridge, so both engines are maintained against one spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Dunky's promise is define a behavior once, render it anywhere. "Anywhere" stopped at JavaScript: a dialog or a command palette could not run in a Rust program, a game, or natively on React Native.
This PR brings the same machines to Rust, as a second engine next to the TypeScript one. Both follow one behavior contract,
packages/core/SPEC.md, with twin test suites. Neither runs on the other, so each stays tuned for its own runtime.What is in it
crates/core(dunky-core): the Rust engine. It has the same model as the TS one (flat states, run-to-completion, guards, actions, effects,aftertimers, computed values, watchers, selections, connector, compose, store), with typed states, events and context (#[derive(State, Event, Context)]) and timers as commands (no clock inside).crates/wasm(export_machine!) turns a Rust machine into a JS class in your own wasm module.@dunky.dev/state-machine-wasmwraps it withfromWasm, behind the sameMachineinterface. Its TS types are generated from the Rust types, and a type test pins the Rust palette's types to the TS palette's.useMachine(React, Solid) now accepts a ready machine. Core exportstoMachine/MachineSource.packages/coreis main's engine. The only additions are exports:toMachine,MachineSource, andmakeBroadcast/makeSelection, so a Rust machine notifies and dedupes exactly like a TS one.?machine=rust(built fromcrates/sandbox).benchmark/ts(the TS suite and its demo, moved unchanged) andbenchmark/rust(the Rust engine natively, plus a 60 fps frame-budget proof).Why two engines, not Rust everywhere
We tried one engine first: the TS package as a thin layer over the Rust engine, compiled to wasm. It worked, but it was not the win it looked like. Part of the TS side still had to mirror the Rust engine (the context, the computed cache and the transition steps stay in JS, next to the user code), and every event paid the JS↔wasm bridge, so it gave up a bit of speed. So we maintain both engines against one spec. That attempt is kept on a local branch for reference.
The Rust engine does not cost a frame
At 60 fps one frame is 16.67 ms. Each workload runs inside one frame, timed frame by frame over 600 frames (release build, Apple M5 Pro):
select_field, the twin of TSselect.context), and leaner notifies.Details:
benchmark/rust/README.md.Known gaps (in
TODO.md)crates/uniffiexposes the Rust palette over JSI, but it still needs the TS facade thatfromWasmgives wasm classes.Changesets
Minor for
@dunky.dev/state-machine,@dunky.dev/react-state-machine,@dunky.dev/solid-state-machine, and the new@dunky.dev/state-machine-wasm. No majors.Testing
fromWasm, the targets accepting a ready machine, and the Rust palette and dialog through real wasm.crates/uniffitests.cargo lint(clippy) for the host and for wasm32;cargo fmt.🤖 Generated with Claude Code