From 681521b25c2ef27cefc16ce03664c9d2186409c6 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:10:47 +0200 Subject: [PATCH 01/28] feat(core): toMachine, makeBroadcast and makeSelection exports - 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 --- .changeset/export-broadcast-selection.md | 27 ++++++++++++++++++++++++ packages/core/src/index.ts | 6 +++++- packages/core/src/machine.ts | 25 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .changeset/export-broadcast-selection.md diff --git a/.changeset/export-broadcast-selection.md b/.changeset/export-broadcast-selection.md new file mode 100644 index 0000000..fa249f7 --- /dev/null +++ b/.changeset/export-broadcast-selection.md @@ -0,0 +1,27 @@ +--- +'@dunky.dev/state-machine': minor +--- + +Export `makeBroadcast` (with its `Broadcast` type) and `makeSelection` — the +notify and select primitives the engine's own `subscribe` and `select` are +built on — for code that implements the `Machine` interface on another +engine. `@dunky.dev/state-machine-wasm` uses them for its Rust-backed +machines, so every implementation shares one set of semantics instead of +re-deriving them: a broadcast that allocates nothing on steady-state notifies +and honors (un)subscribes made mid-notify, and selections that fire only when +the selected value changes (`Object.is`, or the `equals` you pass to +`subscribe`). + +```ts +import { makeBroadcast, makeSelection } from '@dunky.dev/state-machine' + +const broadcast = makeBroadcast() +const count = makeSelection( + () => store.count, + onWake => broadcast.add(onWake), +) +count.subscribe(value => render(value)) // fires only when `count` changed +broadcast.notify() // after every change +``` + +No behavior change for existing consumers. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6be01f8..5ad50c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { machine } from './machine' +export { machine, toMachine, type MachineSource } from './machine' export { MACHINE_INIT } from './constants' export { setup } from './setup' export { and, or, not } from './guards' @@ -7,6 +7,10 @@ export { connector } from './connector' export { makeReaction } from './reaction' export { compose, type Composition } from './compose' export { createStore, type Store, type Listener, type SetStateAction } from './store' +// Building blocks for other Machine implementations (e.g. the wasm adapter), so every +// implementation shares one broadcast and one selection semantics. +export { makeBroadcast, type Broadcast } from './broadcast' +export { makeSelection } from './selection' export type { Machine, diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 0d97796..1f66d6a 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -313,6 +313,31 @@ class MachineClass< } } +/** + * What a bridge can build a service from: a config (run by this engine) or a ready + * machine — e.g. a Rust machine behind `@dunky.dev/state-machine-wasm`. + */ +export type MachineSource< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +> = TransitionConfig | Machine + +/** Build a stopped service from a config; a ready machine is returned as is. */ +export function toMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + source: MachineSource, +): Machine { + return typeof (source as Machine).send === 'function' + ? (source as Machine) + : machine(source as TransitionConfig) +} + export function machine< State extends string, Context extends object, From 9e1bd0811de52db070096e40e62bbb1a969e6fd7 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:10:49 +0200 Subject: [PATCH 02/28] feat(react,solid): useMachine accepts a ready machine 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 --- .changeset/use-machine-accepts-machine.md | 25 +++++++++++++++++++++++ packages/react/src/use-machine.ts | 23 +++++++++++++++------ packages/react/tests/use-machine.test.tsx | 19 +++++++++++++++++ packages/solid/src/use-machine.ts | 23 +++++++++++++++------ packages/solid/tests/use-machine.test.tsx | 19 +++++++++++++++++ 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 .changeset/use-machine-accepts-machine.md diff --git a/.changeset/use-machine-accepts-machine.md b/.changeset/use-machine-accepts-machine.md new file mode 100644 index 0000000..b64c103 --- /dev/null +++ b/.changeset/use-machine-accepts-machine.md @@ -0,0 +1,25 @@ +--- +'@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 outside the TS engine — for example a Rust +machine compiled to wasm and wrapped by `@dunky.dev/state-machine-wasm` — plugs +into the same hook, connector and component effects: + +```ts +// a config, run by the TS engine (unchanged) +useMachine(commandPaletteMachineConfig, connect, effects, props) + +// a ready machine, e.g. Rust via wasm +useMachine(props => createPalette(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. diff --git a/packages/react/src/use-machine.ts b/packages/react/src/use-machine.ts index 541d6f8..74b11af 100644 --- a/packages/react/src/use-machine.ts +++ b/packages/react/src/use-machine.ts @@ -1,5 +1,11 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + connector, + toMachine, + type Connect, + type Machine, + type MachineSource, +} from '@dunky.dev/state-machine' /** * A substrate-specific effect: a setup/teardown function plus the prop names it depends on. @@ -19,9 +25,14 @@ export type ComponentEffect = [ ] /** - * The generic React bridge. Builds the machine once from the first render's props, + * The generic React bridge. Builds the machine once from the first render's props — + * from a config, or as a ready machine (e.g. a Rust machine via wasm) — * keeps props fresh via setProps, runs substrate effects, and drives React via * useSyncExternalStore over the connector's snapshot. + * + * A ready machine must come fresh from `createConfig` (one per component instance): + * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine + * between components is not supported. */ export function useMachine< State extends string, @@ -31,14 +42,14 @@ export function useMachine< Api, Computed = Record, >( - createConfig: (props: Props) => TransitionConfig, + createConfig: (props: Props) => MachineSource, connect: Connect, - effects: ComponentEffect>, Props>[], + effects: ComponentEffect, Props>[], props: Props, -): { api: Api; machine: ReturnType> } { +): { api: Api; machine: Machine } { const { service, connection } = useMemo( () => { - const service = machine(createConfig(props)) + const service = toMachine(createConfig(props)) const connection = connector(service, connect, props) return { service, connection } }, diff --git a/packages/react/tests/use-machine.test.tsx b/packages/react/tests/use-machine.test.tsx index 996aa71..48b2cc3 100644 --- a/packages/react/tests/use-machine.test.tsx +++ b/packages/react/tests/use-machine.test.tsx @@ -117,6 +117,25 @@ describe('useMachine — lifecycle', () => { }) }) +describe('useMachine — machine source', () => { + it('accepts a ready machine instead of a config and runs that very instance', () => { + // The shape a Rust machine takes (built outside the engine, e.g. via wasm). + const ready = machine(createConfig()({})) + const sink: { api?: ToggleApi; machine?: ToggleMachine } = {} + function Comp(p: ToggleProps) { + const result = useMachine(() => ready, connect, noEffects, p) + sink.api = result.api + sink.machine = result.machine + return null + } + render() + expect(sink.machine).toBe(ready) + act(() => sink.api!.toggle()) + expect(ready.state).toBe('open') + expect(sink.api!.open).toBe(true) + }) +}) + describe('useMachine — build once', () => { it('builds the machine ONCE: state survives prop changes (no rebuild)', () => { const { sink, Comp } = harness({ label: 'a' }) diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index 844f393..052cafb 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -1,5 +1,11 @@ import { createEffect, createStore, onCleanup, onSettled, reconcile } from 'solid-js' -import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + connector, + toMachine, + type Connect, + type Machine, + type MachineSource, +} from '@dunky.dev/state-machine' /** * One substrate-specific effect: a setup/teardown function plus the prop names @@ -13,10 +19,15 @@ export type ComponentEffect = [ ] /** - * The generic Solid bridge: builds the machine + connector once, mirrors the + * The generic Solid bridge: builds the machine (from a config, or a ready machine such + * as a Rust machine via wasm) + connector once, mirrors the * connector's snapshot into a fine-grained store, runs the lifecycle and the * component's effects. Returns the connect() api (reactive store proxy) and * the running machine. + * + * A ready machine must come fresh from `createConfig` (one per component instance): + * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine + * between components is not supported. */ export function useMachine< State extends string, @@ -26,14 +37,14 @@ export function useMachine< Api extends object, Computed = Record, >( - createConfig: (props: Props) => TransitionConfig, + createConfig: (props: Props) => MachineSource, connect: Connect, - effects: ComponentEffect>, Props>[], + effects: ComponentEffect, Props>[], props: Props, -): { api: Api; machine: ReturnType> } { +): { api: Api; machine: Machine } { // Seed with a plain copy, never the live props proxy: setProps value-dedups, // and a held proxy would compare equal to its own fresh spread and never wake. - const service = machine(createConfig(props)) + const service = toMachine(createConfig(props)) const connection = connector(service, connect, { ...props }) // Fine-grained mirror of the snapshot: reading `api.x` subscribes to that diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx index 47f5481..f4b449a 100644 --- a/packages/solid/tests/use-machine.test.tsx +++ b/packages/solid/tests/use-machine.test.tsx @@ -68,6 +68,25 @@ const noEffects: ComponentEffect[] = [] afterEach(() => vi.clearAllMocks()) +describe('useMachine — machine source', () => { + it('accepts a ready machine instead of a config and runs that very instance', () => { + // The shape a Rust machine takes (built outside the engine, e.g. via wasm). + const ready = machine(createConfig()({})) + let captured: { api: ToggleApi; machine: ToggleMachine } | undefined + function Comp() { + const props: ToggleProps = {} + captured = useMachine(() => ready, connect, noEffects, props) + return null + } + render(() => ) + expect(captured!.machine).toBe(ready) + captured!.api.toggle() + flush() + expect(ready.state).toBe('open') + expect(captured!.api.open).toBe(true) + }) +}) + describe('useMachine — lifecycle', () => { it('returns { api, machine }: api is the connect() output, machine is the running service', () => { let captured: { api: ToggleApi; machine: ToggleMachine } | undefined From 2ab995e2e65c37fc92ca8ac850a54e5cf769fb1a Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:11:05 +0200 Subject: [PATCH 03/28] feat(rust): port the engine to Rust, with a wasm binding - 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 --- .gitignore | 9 + Cargo.lock | 285 ++++++++++++ Cargo.toml | 38 ++ crates/core/Cargo.toml | 23 + crates/core/README.md | 63 +++ crates/core/src/broadcast.rs | 105 +++++ crates/core/src/compose.rs | 159 +++++++ crates/core/src/computed.rs | 226 +++++++++ crates/core/src/config.rs | 620 +++++++++++++++++++++++++ crates/core/src/connector.rs | 165 +++++++ crates/core/src/lib.rs | 61 +++ crates/core/src/machine.rs | 722 +++++++++++++++++++++++++++++ crates/core/src/params.rs | 110 +++++ crates/core/src/protocol.rs | 64 +++ crates/core/src/selection.rs | 87 ++++ crates/core/src/store.rs | 66 +++ crates/core/src/testing.rs | 68 +++ crates/core/src/timers.rs | 11 + crates/core/src/traits.rs | 141 ++++++ crates/core/tests/act.rs | 126 +++++ crates/core/tests/actions.rs | 391 ++++++++++++++++ crates/core/tests/after.rs | 288 ++++++++++++ crates/core/tests/bare_fn_entry.rs | 76 +++ crates/core/tests/broadcast.rs | 111 +++++ crates/core/tests/common/mod.rs | 181 ++++++++ crates/core/tests/compose.rs | 292 ++++++++++++ crates/core/tests/computed.rs | 366 +++++++++++++++ crates/core/tests/connector.rs | 262 +++++++++++ crates/core/tests/derive.rs | 41 ++ crates/core/tests/effects.rs | 257 ++++++++++ crates/core/tests/guards.rs | 229 +++++++++ crates/core/tests/lifecycle.rs | 116 +++++ crates/core/tests/setup.rs | 89 ++++ crates/core/tests/store.rs | 90 ++++ crates/core/tests/subscribe.rs | 241 ++++++++++ crates/core/tests/transitions.rs | 180 +++++++ crates/core/tests/watch.rs | 329 +++++++++++++ crates/demo-wasm/Cargo.toml | 28 ++ crates/demo-wasm/src/lib.rs | 103 ++++ crates/demo/Cargo.toml | 14 + crates/demo/src/cell.rs | 52 +++ crates/demo/src/dialog.rs | 72 +++ crates/demo/src/guards.rs | 47 ++ crates/demo/src/highlight.rs | 44 ++ crates/demo/src/lib.rs | 30 ++ crates/demo/src/list.rs | 44 ++ crates/demo/src/palette.rs | 333 +++++++++++++ crates/demo/src/pingpong.rs | 44 ++ crates/macros/Cargo.toml | 16 + crates/macros/src/lib.rs | 483 +++++++++++++++++++ crates/wasm/Cargo.toml | 14 + crates/wasm/src/lib.rs | 306 ++++++++++++ scripts/build-wasm.sh | 29 ++ 53 files changed, 8347 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/README.md create mode 100644 crates/core/src/broadcast.rs create mode 100644 crates/core/src/compose.rs create mode 100644 crates/core/src/computed.rs create mode 100644 crates/core/src/config.rs create mode 100644 crates/core/src/connector.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/machine.rs create mode 100644 crates/core/src/params.rs create mode 100644 crates/core/src/protocol.rs create mode 100644 crates/core/src/selection.rs create mode 100644 crates/core/src/store.rs create mode 100644 crates/core/src/testing.rs create mode 100644 crates/core/src/timers.rs create mode 100644 crates/core/src/traits.rs create mode 100644 crates/core/tests/act.rs create mode 100644 crates/core/tests/actions.rs create mode 100644 crates/core/tests/after.rs create mode 100644 crates/core/tests/bare_fn_entry.rs create mode 100644 crates/core/tests/broadcast.rs create mode 100644 crates/core/tests/common/mod.rs create mode 100644 crates/core/tests/compose.rs create mode 100644 crates/core/tests/computed.rs create mode 100644 crates/core/tests/connector.rs create mode 100644 crates/core/tests/derive.rs create mode 100644 crates/core/tests/effects.rs create mode 100644 crates/core/tests/guards.rs create mode 100644 crates/core/tests/lifecycle.rs create mode 100644 crates/core/tests/setup.rs create mode 100644 crates/core/tests/store.rs create mode 100644 crates/core/tests/subscribe.rs create mode 100644 crates/core/tests/transitions.rs create mode 100644 crates/core/tests/watch.rs create mode 100644 crates/demo-wasm/Cargo.toml create mode 100644 crates/demo-wasm/src/lib.rs create mode 100644 crates/demo/Cargo.toml create mode 100644 crates/demo/src/cell.rs create mode 100644 crates/demo/src/dialog.rs create mode 100644 crates/demo/src/guards.rs create mode 100644 crates/demo/src/highlight.rs create mode 100644 crates/demo/src/lib.rs create mode 100644 crates/demo/src/list.rs create mode 100644 crates/demo/src/palette.rs create mode 100644 crates/demo/src/pingpong.rs create mode 100644 crates/macros/Cargo.toml create mode 100644 crates/macros/src/lib.rs create mode 100644 crates/wasm/Cargo.toml create mode 100644 crates/wasm/src/lib.rs create mode 100755 scripts/build-wasm.sh diff --git a/.gitignore b/.gitignore index f107741..24e93f2 100644 --- a/.gitignore +++ b/.gitignore @@ -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`) +packages/demo-wasm/pkg + +# Expo prebuild output (`expo prebuild` / `expo run:*` in the RN sandbox) +sandbox/native/ios +sandbox/native/android diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a5ce33d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,285 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "dunky-core" +version = "0.1.0" +dependencies = [ + "dunky-macros", + "serde", + "serde_json", +] + +[[package]] +name = "dunky-demo" +version = "0.1.0" +dependencies = [ + "dunky-core", + "serde", + "serde_json", +] + +[[package]] +name = "dunky-demo-wasm" +version = "0.1.0" +dependencies = [ + "dunky-core", + "dunky-demo", + "dunky-wasm", + "wasm-bindgen", +] + +[[package]] +name = "dunky-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunky-wasm" +version = "0.1.0" +dependencies = [ + "dunky-core", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..364a064 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +# Rust port of the engine. Members are listed explicitly so a half-built crate +# never breaks `cargo test --workspace` for the others. +[workspace] +resolver = "3" +members = ["crates/core", "crates/macros", "crates/wasm", "crates/demo", "crates/demo-wasm"] +# Built by its own pipeline (uniffi-bindgen-react-native) for iOS/Android. +exclude = ["crates/uniffi"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/dunky-dev/state-machine" +rust-version = "1.85" + +[workspace.dependencies] +dunky-core = { path = "crates/core" } +dunky-macros = { path = "crates/macros" } +dunky-wasm = { path = "crates/wasm" } +dunky-demo = { path = "crates/demo" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Must match the installed wasm-bindgen CLI exactly (wasm-bindgen --version). +wasm-bindgen = "=0.2.129" +js-sys = "0.3" +serde-wasm-bindgen = "0.6" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" + +# Size-tuned profile for the shipped .wasm. +[profile.wasm] +inherits = "release" +opt-level = "z" +strip = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..7292ccf --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "dunky-core" +description = "Substrate-agnostic state-machine engine — the Rust port of @dunky.dev/state-machine." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] +default = ["derive", "serde"] +# #[derive(State, Event, Context)] +derive = ["dep:dunky-macros"] +# Per-field serialization (SerializeFields) for bindings that mirror context into JS. +serde = ["dep:serde"] + +[dependencies] +dunky-macros = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[dev-dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/core/README.md b/crates/core/README.md new file mode 100644 index 0000000..549c3c6 --- /dev/null +++ b/crates/core/README.md @@ -0,0 +1,63 @@ +# dunky-core + +The Rust port of [`@dunky.dev/state-machine`](../../packages/core). Same engine, same +behavior contract — [`packages/core/SPEC.md`](../../packages/core/SPEC.md) is the spec for +both; the Rust tests in `tests/` are ported from `packages/core/tests`. + +```rust +use dunky_core::{Config, Context, Event, Machine, State, Types}; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum DialogState { Closed, Open } + +#[derive(Event, Clone, Debug)] +enum DialogEvent { Open, Close } + +#[derive(Context, Clone, Debug, PartialEq)] +struct DialogCtx { open_count: u32 } + +struct Dialog; +impl Types for Dialog { + type State = DialogState; + type Event = DialogEvent; + type Context = DialogCtx; +} + +let mut b = Config::::builder(DialogState::Closed, DialogCtx { open_count: 0 }); +b.action("count", |p| { + let n = p.context().open_count + 1; + p.set_context(DialogCtx::patch().open_count(n)); +}); +b.state(DialogState::Closed, |s| s.on(DialogEventKind::Open, |t| t.target(DialogState::Open).action("count"))); +b.state(DialogState::Open, |s| s.on(DialogEventKind::Close, |t| t.target(DialogState::Closed))); +let config = b.build(); + +let dialog = Machine::new(&config); +dialog.start(); +dialog.send(DialogEvent::Open); +assert_eq!(dialog.state(), DialogState::Open); +``` + +## Where it differs from the TS engine + +- **Typed.** States, events and context are Rust types. `#[derive(Event)]` generates the + `Kind` enum that transitions are keyed by; `#[derive(Context)]` generates the + `Patch` builder (`Ctx::patch().field(value)`), one `Field` const per field, and a + tracked reader so computed values record what they read. +- **Sans-IO timers.** The core owns no clock. `after` emits `Command::StartTimer` / + `Command::CancelTimer` (`Machine::take_commands`); the host runs them and calls + `Machine::fire_timer(id)`. Tests use `testing::Clock`. +- **Change mask.** `Machine::take_changes` reports which fields (and whether the state) + changed, so a binding mirrors only those into another runtime. +- **Borrow discipline.** Actions, guards, effects, delays and computed definitions get + params, not the machine handle. Observers (subscriptions, selections, lifecycle + listeners, effect cleanups) run with no borrow held and may read, send or patch. +- **Equality** is `PartialEq` per field (TS: `Object.is`). +- **A missing named implementation** panics in debug builds and warns + no-ops in release + builds (TS: throws in dev, warns in prod). + +## Bindings + +- [`dunky-wasm`](../wasm): `export_machine!` turns a machine type into a JS class for + [`@dunky.dev/state-machine-wasm`](../../packages/wasm). +- `crates/uniffi` (React Native through JSI) — see its README. diff --git a/crates/core/src/broadcast.rs b/crates/core/src/broadcast.rs new file mode 100644 index 0000000..f9c1bad --- /dev/null +++ b/crates/core/src/broadcast.rs @@ -0,0 +1,105 @@ +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +type Listener = Rc; + +/// Payload-less one-to-all notify. +/// +/// Steady-state notifies allocate nothing: iteration runs over a cached snapshot, rebuilt +/// only when membership changes. Mid-pass (un)subscribes still take effect within the pass — +/// a dirty flag flips iteration to membership-checked mode, and a swapped snapshot (from a +/// nested notify) counts as mid-pass churn too. +pub(crate) struct Broadcast { + listeners: RefCell>, + snapshot: RefCell>, + dirty: Cell, + next_id: Cell, +} + +impl Broadcast { + pub(crate) fn new() -> Rc { + Rc::new(Self { + listeners: RefCell::new(Vec::new()), + snapshot: RefCell::new(Rc::from(Vec::new())), + dirty: Cell::new(false), + next_id: Cell::new(0), + }) + } + + pub(crate) fn add(self: &Rc, listener: Listener) -> Subscription { + let id = self.next_id.get(); + self.next_id.set(id + 1); + self.listeners.borrow_mut().push((id, listener)); + self.dirty.set(true); + let weak: Weak = Rc::downgrade(self); + Subscription::new(move || { + if let Some(bus) = weak.upgrade() { + bus.remove(id); + } + }) + } + + fn remove(&self, id: u64) { + let mut listeners = self.listeners.borrow_mut(); + if let Some(pos) = listeners.iter().position(|(i, _)| *i == id) { + listeners.remove(pos); + self.dirty.set(true); + } + } + + fn has(&self, id: u64) -> bool { + self.listeners.borrow().iter().any(|(i, _)| *i == id) + } + + pub(crate) fn notify(&self) { + if self.dirty.get() { + let fresh: Rc<[(u64, Listener)]> = self.listeners.borrow().iter().cloned().collect(); + *self.snapshot.borrow_mut() = fresh; + self.dirty.set(false); + } + let snap = self.snapshot.borrow().clone(); + for (id, listener) in snap.iter() { + let undisturbed = !self.dirty.get() && Rc::ptr_eq(&snap, &self.snapshot.borrow()); + if undisturbed || self.has(*id) { + listener(); + } + } + } + + pub(crate) fn clear(&self) { + self.listeners.borrow_mut().clear(); + self.dirty.set(true); + } + + pub(crate) fn len(&self) -> usize { + self.listeners.borrow().len() + } +} + +/// A live subscription. Call [`Subscription::unsubscribe`] to detach; dropping the handle +/// does NOT detach (like the bare unsubscribe function the TS API returns). +#[must_use = "dropping a Subscription keeps the listener attached; call unsubscribe() to detach"] +pub struct Subscription { + off: Option>, +} + +impl Subscription { + /// A subscription whose `unsubscribe` runs `off` (for custom [`Member`](crate::Member) + /// implementations). + pub fn new(off: impl FnOnce() + 'static) -> Self { + Self { + off: Some(Box::new(off)), + } + } + pub fn unsubscribe(mut self) { + if let Some(off) = self.off.take() { + off(); + } + } +} + +impl std::fmt::Debug for Subscription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Subscription") + } +} diff --git a/crates/core/src/compose.rs b/crates/core/src/compose.rs new file mode 100644 index 0000000..36f5834 --- /dev/null +++ b/crates/core/src/compose.rs @@ -0,0 +1,159 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::broadcast::Subscription; +use crate::machine::Machine; +use crate::traits::Types; + +/// The lifecycle + observation surface a composition needs from any machine. +pub trait Member { + fn start(&self); + fn stop(&self); + fn subscribe_any(&self, listener: Rc) -> Subscription; +} + +impl Member for Machine { + fn start(&self) { + Machine::start(self) + } + fn stop(&self) { + Machine::stop(self) + } + fn subscribe_any(&self, listener: Rc) -> Subscription { + self.subscribe(move || listener()) + } +} + +type Registry = Rc)>>>; + +/// Several machines run as one unit (orthogonal regions): shared start/stop plus +/// cross-member `sync` and `combine`. Members stay independent — keep your own handles +/// to read and send to each. +pub struct Composition { + members: Vec>, + registry: Registry, + next_id: RefCell, +} + +impl Default for Composition { + fn default() -> Self { + Self::new() + } +} + +impl Composition { + pub fn new() -> Self { + Self { + members: Vec::new(), + registry: Rc::new(RefCell::new(Vec::new())), + next_id: RefCell::new(0), + } + } + + /// Add a member. Members start in the order they are added. + pub fn with(mut self, member: &M) -> Self { + self.members.push(Rc::new(member.clone())); + self + } + + pub fn start(&self) { + for m in &self.members { + m.start(); + } + } + + /// Dispose every sync/combine subscription, then stop members in reverse order. + pub fn stop(&self) { + let all = std::mem::take(&mut *self.registry.borrow_mut()); + for (_, subs) in all { + for s in subs { + s.unsubscribe(); + } + } + for m in self.members.iter().rev() { + m.stop(); + } + } + + fn register(&self, subs: Vec) -> Subscription { + let id = { + let mut next = self.next_id.borrow_mut(); + *next += 1; + *next + }; + self.registry.borrow_mut().push((id, subs)); + let registry = Rc::downgrade(&self.registry); + Subscription::new(move || { + let Some(registry) = registry.upgrade() else { + return; + }; + let found = { + let mut reg = registry.borrow_mut(); + reg.iter() + .position(|(i, _)| *i == id) + .map(|pos| reg.remove(pos)) + }; + if let Some((_, subs)) = found { + for s in subs { + s.unsubscribe(); + } + } + }) + } + + /// Run `reaction` whenever any member changes. Does not fire on setup; disposed on stop. + pub fn sync(&self, reaction: impl Fn() + 'static) -> Subscription { + let reaction: Rc = Rc::new(reaction); + let subs = self + .members + .iter() + .map(|m| m.subscribe_any(reaction.clone())) + .collect(); + self.register(subs) + } + + /// A value-deduped derivation across members. + pub fn combine(&self, selector: impl Fn() -> V + 'static) -> Combined<'_, V> { + Combined { + composition: self, + selector: Rc::new(selector), + } + } +} + +/// A value-deduped selection across a composition's members. +pub struct Combined<'c, V> { + composition: &'c Composition, + selector: Rc V>, +} + +impl Combined<'_, V> { + pub fn value(&self) -> V { + (self.selector)() + } + + /// Fires only when the combined value changes. Disposed with the composition's stop. + pub fn subscribe(&self, listener: impl Fn(&V) + 'static) -> Subscription { + let selector = self.selector.clone(); + // `Rc` so the listener runs with no borrow of `prev` held (it may send to a member, + // which notifies and re-enters this wake). + let prev = Rc::new(RefCell::new(Rc::new(selector()))); + let listener = Rc::new(listener); + let wake: Rc = Rc::new(move || { + let next = selector(); + if **prev.borrow() == next { + return; + } + let next = Rc::new(next); + *prev.borrow_mut() = next.clone(); + listener(&next); + }); + let subs = self + .composition + .members + .iter() + .map(|m| m.subscribe_any(wake.clone())) + .collect(); + self.composition.register(subs) + } +} diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs new file mode 100644 index 0000000..b848c09 --- /dev/null +++ b/crates/core/src/computed.rs @@ -0,0 +1,226 @@ +//! Derived data: lazy, memoized, dependency-tracked per field. +//! +//! Each computed value records what it read on its last evaluation — context fields (as a +//! bit mask, through the tracked reader), other computed values, and whether it read the +//! state. It recomputes only when one of those inputs changed since. Change detection is +//! by tick: every effective write bumps a machine-wide tick and stamps the written fields; +//! a computed value is stale iff an input was stamped after the value was last validated. + +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::marker::PhantomData; +use std::rc::Rc; + +use crate::machine::{Core, Inner}; +use crate::traits::{Context, Types}; + +pub(crate) type ComputedEval = Rc Fn(&ComputedParams<'a, T>) -> Rc>; + +pub(crate) struct ComputedDef { + pub(crate) name: &'static str, + pub(crate) eval: ComputedEval, + pub(crate) eq: fn(&dyn Any, &dyn Any) -> bool, +} + +/// A typed handle to one computed value, returned by `ConfigBuilder::computed`. +pub struct ComputedKey { + id: usize, + _value: PhantomData V>, +} + +impl ComputedKey { + /// Keys are numbered in definition order; a const key must match that order. + pub const fn new(id: usize) -> Self { + Self { + id, + _value: PhantomData, + } + } + pub const fn id(self) -> usize { + self.id + } +} + +impl Clone for ComputedKey { + fn clone(&self) -> Self { + *self + } +} +impl Copy for ComputedKey {} +impl std::fmt::Debug for ComputedKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ComputedKey({})", self.id) + } +} + +/// What a computed definition reads. Reads are tracked: `context.()`, `state()` and +/// `computed(key)` each become an input of this value. +pub struct ComputedParams<'a, T: Types> { + pub context: ::Reader<'a>, + core: &'a Core, + inner: &'a Inner, + computed_deps: &'a RefCell>, + reads_state: &'a Cell, +} + +impl ComputedParams<'_, T> { + pub fn state(&self) -> T::State { + self.reads_state.set(true); + self.core.state + } + pub fn computed(&self, key: ComputedKey) -> Rc { + { + let mut deps = self.computed_deps.borrow_mut(); + if !deps.contains(&key.id) { + deps.push(key.id); + } + } + downcast(get(self.core, self.inner, key.id), self.inner, key.id) + } +} + +#[derive(Default)] +pub(crate) struct Slot { + value: Option>, + /// Tick at which the value was last computed or validated. + validated_at: u64, + /// Tick at which the value last changed (by `eq`). + changed_at: u64, + ctx_deps: u64, + reads_state: bool, + computed_deps: Vec, + evaluating: bool, +} + +pub(crate) struct ComputedRuntime { + slots: Vec>, +} + +impl ComputedRuntime { + pub(crate) fn new(count: usize) -> Self { + Self { + slots: (0..count).map(|_| RefCell::new(Slot::default())).collect(), + } + } +} + +pub(crate) fn downcast( + value: Rc, + inner: &Inner, + id: usize, +) -> Rc { + value.downcast::().unwrap_or_else(|_| { + panic!( + "[machine] computed \"{}\" read with the wrong type", + inner.config.0.computed[id].name + ) + }) +} + +/// The current value of computed `id`, recomputing it only if an input changed. +pub(crate) fn get(core: &Core, inner: &Inner, id: usize) -> Rc { + if let Some(value) = fresh(core, inner, id) { + return value; + } + recompute(core, inner, id) +} + +/// The tick at which computed `id` last changed value (after bringing it up to date). +/// Bindings compare it to skip re-reading an unchanged value. +pub(crate) fn changed_at(core: &Core, inner: &Inner, id: usize) -> u64 { + get(core, inner, id); + core.computed.slots[id].borrow().changed_at +} + +/// The value of `id` if no input changed since it was validated (re-validating it). +fn fresh(core: &Core, inner: &Inner, id: usize) -> Option> { + let slots = &core.computed.slots; + let (at, n) = { + let slot = slots[id].borrow(); + slot.value.as_ref()?; + if slot.validated_at == core.tick { + return slot.value.clone(); + } + let at = slot.validated_at; + let mut bits = slot.ctx_deps; + while bits != 0 { + let i = bits.trailing_zeros() as usize; + if core.field_changed_at[i] > at { + return None; + } + bits &= bits - 1; + } + if slot.reads_state && core.state_changed_at > at { + return None; + } + (at, slot.computed_deps.len()) + }; + // A computed input resolves its own staleness first, so changes surface transitively. + for k in 0..n { + let dep = slots[id].borrow().computed_deps[k]; + get(core, inner, dep); + if slots[dep].borrow().changed_at > at { + return None; + } + } + let mut slot = slots[id].borrow_mut(); + slot.validated_at = core.tick; + slot.value.clone() +} + +fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc { + let def = &inner.config.0.computed[id]; + let slot_cell = &core.computed.slots[id]; + let buffer = { + let mut slot = slot_cell.borrow_mut(); + if slot.evaluating { + panic!("[machine] computed \"{}\" depends on itself", def.name); + } + slot.evaluating = true; + let mut buffer = std::mem::take(&mut slot.computed_deps); + buffer.clear(); + buffer + }; + // A panicking definition must not leave the slot flagged as evaluating. + struct Reset<'s>(&'s RefCell); + impl Drop for Reset<'_> { + fn drop(&mut self) { + if let Ok(mut slot) = self.0.try_borrow_mut() { + if slot.evaluating { + slot.evaluating = false; + slot.value = None; + } + } + } + } + let reset = Reset(slot_cell); + + let ctx_deps = Cell::new(0u64); + let computed_deps = RefCell::new(buffer); + let reads_state = Cell::new(false); + let params = ComputedParams { + context: core.ctx.reader(&ctx_deps), + core, + inner, + computed_deps: &computed_deps, + reads_state: &reads_state, + }; + let next = (def.eval)(¶ms); + + let mut slot = slot_cell.borrow_mut(); + slot.evaluating = false; + std::mem::forget(reset); + let changed = match &slot.value { + Some(prev) => !(def.eq)(&**prev, &*next), + None => true, + }; + if changed { + slot.value = Some(next); + slot.changed_at = core.tick; + } + slot.validated_at = core.tick; + slot.ctx_deps = ctx_deps.get(); + slot.reads_state = reads_state.get(); + slot.computed_deps = computed_deps.into_inner(); + slot.value.clone().expect("computed value just set") +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..e42f0d1 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,620 @@ +use std::any::Any; +use std::collections::HashMap; +use std::rc::Rc; + +use crate::computed::{ComputedDef, ComputedKey, ComputedParams}; +use crate::params::{ActionParams, GuardParams}; +use crate::traits::{Context, EventEnum, Field, StateEnum, Types}; + +pub type ActionFn = Rc)>; +pub type GuardFn = Rc) -> bool>; +pub type EffectFn = Rc) -> Option>; +pub type DelayFn = Rc) -> u32>; +/// What an effect returns to undo itself; run when its state is left or the machine stops. +pub type Cleanup = Box; + +/// A reference to a named implementation. Resolved once, when the config is built. +pub struct Named { + pub(crate) name: &'static str, + pub(crate) imp: Option, +} + +impl Clone for Named { + fn clone(&self) -> Self { + Self { + name: self.name, + imp: self.imp.clone(), + } + } +} + +/// A guard: an inline predicate, a named one, or a combination. +pub enum Guard { + Fn(GuardFn), + Named(Named>), + /// True iff every guard passes. Empty → true. + And(Vec>), + /// True iff any guard passes. Empty → false. + Or(Vec>), + Not(Box>), +} + +impl Guard { + pub fn when(f: impl Fn(&GuardParams<'_, T>) -> bool + 'static) -> Self { + Guard::Fn(Rc::new(f)) + } + pub fn named(name: &'static str) -> Self { + Guard::Named(Named { name, imp: None }) + } + pub fn and(guards: impl IntoIterator>) -> Self { + Guard::And(guards.into_iter().collect()) + } + pub fn or(guards: impl IntoIterator>) -> Self { + Guard::Or(guards.into_iter().collect()) + } + #[allow(clippy::should_implement_trait)] + pub fn not(guard: Guard) -> Self { + Guard::Not(Box::new(guard)) + } +} + +impl From<&'static str> for Guard { + fn from(name: &'static str) -> Self { + Guard::named(name) + } +} + +/// An action: an inline fn, a named one, or a conditional (`one_of`). +pub enum Action { + Fn(ActionFn), + Named(Named>), + OneOf(Vec>), +} + +impl Action { + pub fn run(f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + Action::Fn(Rc::new(f)) + } + pub fn named(name: &'static str) -> Self { + Action::Named(Named { name, imp: None }) + } + /// Write-sugar for the most common action: patch the context. + pub fn act( + patch: impl Fn(&ActionParams<'_, T>) -> ::Patch + 'static, + ) -> Self { + Action::Fn(Rc::new(move |p: &mut ActionParams<'_, T>| { + let next = patch(p); + p.set_context(next); + })) + } + /// Run the first branch whose guard passes; a guardless branch is the fallback. + pub fn one_of(branches: impl IntoIterator>) -> Self { + Action::OneOf(branches.into_iter().collect()) + } +} + +impl From<&'static str> for Action { + fn from(name: &'static str) -> Self { + Action::named(name) + } +} + +/// One branch of a `one_of`. +pub struct Branch { + pub(crate) guard: Option>, + pub(crate) actions: Vec>, +} + +impl Branch { + pub fn when(guard: impl Into>, actions: impl IntoIterator>) -> Self { + Self { + guard: Some(guard.into()), + actions: actions.into_iter().collect(), + } + } + pub fn otherwise(actions: impl IntoIterator>) -> Self { + Self { + guard: None, + actions: actions.into_iter().collect(), + } + } +} + +/// An effect: started when its state is entered, cleaned up when it is left. +pub enum Effect { + Fn(EffectFn), + Named(Named>), +} + +impl Effect { + pub fn run(f: impl Fn(&mut ActionParams<'_, T>) -> Option + 'static) -> Self { + Effect::Fn(Rc::new(f)) + } + pub fn named(name: &'static str) -> Self { + Effect::Named(Named { name, imp: None }) + } +} + +impl From<&'static str> for Effect { + fn from(name: &'static str) -> Self { + Effect::named(name) + } +} + +/// A delay for an `after` transition: fixed milliseconds or a named, dynamic delay. +pub enum Delay { + Ms(u32), + Named(Named>), +} + +impl From for Delay { + fn from(ms: u32) -> Self { + Delay::Ms(ms) + } +} + +impl From<&'static str> for Delay { + fn from(name: &'static str) -> Self { + Delay::Named(Named { name, imp: None }) + } +} + +/// One transition: optional target, optional guard, actions in order. +pub struct Transition { + pub(crate) target: Option, + pub(crate) guard: Option>, + pub(crate) actions: Vec>, +} + +/// Builds one transition candidate inside `StateBuilder::on` / `after`. +pub struct TransitionBuilder(Transition); + +impl TransitionBuilder { + pub fn target(mut self, state: T::State) -> Self { + self.0.target = Some(state); + self + } + /// A named guard. + pub fn guard(mut self, name: &'static str) -> Self { + self.0.guard = Some(Guard::named(name)); + self + } + /// An inline guard. + pub fn guard_fn(mut self, f: impl Fn(&GuardParams<'_, T>) -> bool + 'static) -> Self { + self.0.guard = Some(Guard::when(f)); + self + } + /// Any guard value, e.g. `Guard::and([...])`. + pub fn guard_expr(mut self, guard: Guard) -> Self { + self.0.guard = Some(guard); + self + } + /// A named action. + pub fn action(mut self, name: &'static str) -> Self { + self.0.actions.push(Action::named(name)); + self + } + /// An inline action. + pub fn run(mut self, f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + self.0.actions.push(Action::run(f)); + self + } + /// An inline context patch. + pub fn act( + mut self, + patch: impl Fn(&ActionParams<'_, T>) -> ::Patch + 'static, + ) -> Self { + self.0.actions.push(Action::act(patch)); + self + } + /// Any action value, e.g. `Action::one_of([...])`. + pub fn push(mut self, action: Action) -> Self { + self.0.actions.push(action); + self + } +} + +/// A handler for one event (or one `after` delay): candidates in order, first passing guard wins. +pub(crate) type Entry = Vec>; + +pub(crate) struct StateNode { + /// Indexed by event kind. + pub(crate) on: Vec>, + pub(crate) entry: Vec>, + pub(crate) exit: Vec>, + pub(crate) effects: Vec>, + pub(crate) after: Vec<(Delay, Entry)>, + pub(crate) tags: Vec<&'static str>, +} + +impl StateNode { + fn new(kinds: usize) -> Self { + Self { + on: (0..kinds).map(|_| Vec::new()).collect(), + entry: Vec::new(), + exit: Vec::new(), + effects: Vec::new(), + after: Vec::new(), + tags: Vec::new(), + } + } +} + +/// Builds one state inside `ConfigBuilder::state`. +pub struct StateBuilder(StateNode); + +impl StateBuilder { + /// Add a candidate for `kind`. Calling `on` again for the same kind appends the next + /// candidate (guard fallthrough: the first whose guard passes wins). + pub fn on( + mut self, + kind: ::Kind, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> Self { + let t = build(TransitionBuilder(empty_transition())); + self.0.on[::kind_index(kind)].push(t.0); + self + } + pub fn entry(mut self, action: impl Into>) -> Self { + self.0.entry.push(action.into()); + self + } + pub fn entry_run(mut self, f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + self.0.entry.push(Action::run(f)); + self + } + pub fn exit(mut self, action: impl Into>) -> Self { + self.0.exit.push(action.into()); + self + } + pub fn exit_run(mut self, f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + self.0.exit.push(Action::run(f)); + self + } + pub fn effect(mut self, effect: impl Into>) -> Self { + self.0.effects.push(effect.into()); + self + } + pub fn effect_run( + mut self, + f: impl Fn(&mut ActionParams<'_, T>) -> Option + 'static, + ) -> Self { + self.0.effects.push(Effect::run(f)); + self + } + /// A timed transition while in this state. Calling `after` again with an equal delay + /// adds an independent timer (like a second key in the TS `after` map). + pub fn after( + mut self, + delay: impl Into>, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> Self { + let t = build(TransitionBuilder(empty_transition())); + self.0.after.push((delay.into(), vec![t.0])); + self + } + /// Add a fallthrough candidate to the most recent `after`. + pub fn after_or( + mut self, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> Self { + let t = build(TransitionBuilder(empty_transition())); + self.0 + .after + .last_mut() + .expect("after_or() needs a preceding after()") + .1 + .push(t.0); + self + } + pub fn tag(mut self, tag: &'static str) -> Self { + self.0.tags.push(tag); + self + } +} + +fn empty_transition() -> Transition { + Transition { + target: None, + guard: None, + actions: Vec::new(), + } +} + +pub(crate) enum WatchSource { + Field(usize), + Computed(usize), +} + +pub(crate) struct Watch { + pub(crate) source: WatchSource, + pub(crate) actions: Vec>, +} + +pub(crate) struct ConfigInner { + pub(crate) initial: T::State, + pub(crate) context: T::Context, + pub(crate) states: Vec>, + /// Any-state handlers, indexed by event kind. + pub(crate) on_any: Vec>, + pub(crate) computed: Vec>, + pub(crate) watch: Vec>, + pub(crate) guards: HashMap<&'static str, GuardFn>, +} + +/// A built machine config — static description, shared by every machine built from it. +pub struct Config(pub(crate) Rc>); + +impl Clone for Config { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Config { + pub fn builder(initial: T::State, context: T::Context) -> ConfigBuilder { + let kinds = ::KIND_NAMES.len(); + ConfigBuilder { + inner: ConfigInner { + initial, + context, + states: (0..::NAMES.len()) + .map(|_| StateNode::new(kinds)) + .collect(), + on_any: (0..kinds).map(|_| Vec::new()).collect(), + computed: Vec::new(), + watch: Vec::new(), + guards: HashMap::new(), + }, + actions: HashMap::new(), + effects: HashMap::new(), + delays: HashMap::new(), + } + } + + pub fn initial(&self) -> T::State { + self.0.initial + } + + /// The seed context every new machine copies. + pub fn context(&self) -> &T::Context { + &self.0.context + } + + pub fn computed_names(&self) -> impl Iterator + '_ { + self.0.computed.iter().map(|d| d.name) + } + + pub fn tags(&self, state: T::State) -> &[&'static str] { + &self.0.states[state.index()].tags + } + + /// The handler for `kind` in `state`: the state's own, else the any-state one. + pub(crate) fn lookup(&self, state: T::State, kind: usize) -> Option<&Entry> { + let own = &self.0.states[state.index()].on[kind]; + if !own.is_empty() { + return Some(own); + } + let any = &self.0.on_any[kind]; + (!any.is_empty()).then_some(any) + } +} + +/// Builds a [`Config`]. Register named implementations and computed values first, then +/// describe the states; `build()` resolves every name. +pub struct ConfigBuilder { + inner: ConfigInner, + actions: HashMap<&'static str, ActionFn>, + effects: HashMap<&'static str, EffectFn>, + delays: HashMap<&'static str, DelayFn>, +} + +impl ConfigBuilder { + pub fn state( + &mut self, + state: T::State, + build: impl FnOnce(StateBuilder) -> StateBuilder, + ) -> &mut Self { + let index = state.index(); + let kinds = ::KIND_NAMES.len(); + let node = std::mem::replace(&mut self.inner.states[index], StateNode::new(kinds)); + self.inner.states[index] = build(StateBuilder(node)).0; + self + } + + /// An any-state handler. A state's own handler for the same kind wins. + pub fn on( + &mut self, + kind: ::Kind, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> &mut Self { + let t = build(TransitionBuilder(empty_transition())); + self.inner.on_any[::kind_index(kind)].push(t.0); + self + } + + pub fn guard( + &mut self, + name: &'static str, + f: impl Fn(&GuardParams<'_, T>) -> bool + 'static, + ) -> &mut Self { + self.inner.guards.insert(name, Rc::new(f)); + self + } + + pub fn action( + &mut self, + name: &'static str, + f: impl Fn(&mut ActionParams<'_, T>) + 'static, + ) -> &mut Self { + self.actions.insert(name, Rc::new(f)); + self + } + + pub fn effect( + &mut self, + name: &'static str, + f: impl Fn(&mut ActionParams<'_, T>) -> Option + 'static, + ) -> &mut Self { + self.effects.insert(name, Rc::new(f)); + self + } + + pub fn delay( + &mut self, + name: &'static str, + f: impl Fn(&GuardParams<'_, T>) -> u32 + 'static, + ) -> &mut Self { + self.delays.insert(name, Rc::new(f)); + self + } + + /// Derived data: lazy, memoized, recomputed only when a field (or computed value, or the + /// state) it read has changed. Returns the typed key used to read it. + pub fn computed( + &mut self, + name: &'static str, + f: impl for<'a> Fn(&ComputedParams<'a, T>) -> V + 'static, + ) -> ComputedKey { + let id = self.inner.computed.len(); + self.inner.computed.push(ComputedDef { + name, + eval: Rc::new(move |p: &ComputedParams<'_, T>| Rc::new(f(p)) as Rc), + eq: eq_any::, + }); + ComputedKey::new(id) + } + + /// Data-reaction on a context field: its actions run (deferred) whenever the field + /// changes, in any state, while the machine runs. + pub fn watch( + &mut self, + field: Field, + actions: impl IntoIterator>, + ) -> &mut Self { + self.inner.watch.push(Watch { + source: WatchSource::Field(field.index()), + actions: actions.into_iter().collect(), + }); + self + } + + /// Data-reaction on a computed value. + pub fn watch_computed( + &mut self, + key: ComputedKey, + actions: impl IntoIterator>, + ) -> &mut Self { + self.inner.watch.push(Watch { + source: WatchSource::Computed(key.id()), + actions: actions.into_iter().collect(), + }); + self + } + + /// Resolve every named reference. A name with no implementation stays unresolved and + /// fails when it runs: a panic in debug builds, a warning + no-op in release builds. + pub fn build(&mut self) -> Config { + let kinds = ::KIND_NAMES.len(); + let empty = ConfigInner { + initial: self.inner.initial, + context: self.inner.context.clone(), + states: Vec::new(), + on_any: (0..kinds).map(|_| Vec::new()).collect(), + computed: Vec::new(), + watch: Vec::new(), + guards: HashMap::new(), + }; + let mut inner = std::mem::replace(&mut self.inner, empty); + let reg = Registry { + guards: &inner.guards.clone(), + actions: &self.actions, + effects: &self.effects, + delays: &self.delays, + }; + for node in &mut inner.states { + for entry in &mut node.on { + reg.entry(entry); + } + reg.actions(&mut node.entry); + reg.actions(&mut node.exit); + for effect in &mut node.effects { + if let Effect::Named(n) = effect { + n.imp = reg.effects.get(n.name).cloned(); + } + } + for (delay, entry) in &mut node.after { + if let Delay::Named(n) = delay { + n.imp = reg.delays.get(n.name).cloned(); + } + reg.entry(entry); + } + } + for entry in &mut inner.on_any { + reg.entry(entry); + } + for w in &mut inner.watch { + reg.actions(&mut w.actions); + } + Config(Rc::new(inner)) + } +} + +struct Registry<'r, T: Types> { + guards: &'r HashMap<&'static str, GuardFn>, + actions: &'r HashMap<&'static str, ActionFn>, + effects: &'r HashMap<&'static str, EffectFn>, + delays: &'r HashMap<&'static str, DelayFn>, +} + +impl Registry<'_, T> { + fn entry(&self, entry: &mut Entry) { + for t in entry { + if let Some(g) = &mut t.guard { + self.guard(g); + } + self.actions(&mut t.actions); + } + } + fn guard(&self, guard: &mut Guard) { + match guard { + Guard::Fn(_) => {} + Guard::Named(n) => n.imp = self.guards.get(n.name).cloned(), + Guard::And(gs) | Guard::Or(gs) => gs.iter_mut().for_each(|g| self.guard(g)), + Guard::Not(g) => self.guard(g), + } + } + fn actions(&self, actions: &mut [Action]) { + for a in actions { + match a { + Action::Fn(_) => {} + Action::Named(n) => n.imp = self.actions.get(n.name).cloned(), + Action::OneOf(branches) => { + for b in branches { + if let Some(g) = &mut b.guard { + self.guard(g); + } + self.actions(&mut b.actions); + } + } + } + } + } +} + +fn eq_any(a: &dyn Any, b: &dyn Any) -> bool { + match (a.downcast_ref::(), b.downcast_ref::()) { + (Some(a), Some(b)) => a == b, + _ => false, + } +} + +/// The dev/prod rule for a missing named implementation, shared by every resolver. +pub(crate) fn missing(kind: &str, name: &str) { + let msg = format!("[machine] no {kind} \"{name}\""); + if cfg!(debug_assertions) { + panic!("{msg}"); + } + #[cfg(not(target_arch = "wasm32"))] + eprintln!("{msg}"); +} diff --git a/crates/core/src/connector.rs b/crates/core/src/connector.rs new file mode 100644 index 0000000..141425d --- /dev/null +++ b/crates/core/src/connector.rs @@ -0,0 +1,165 @@ +use std::cell::RefCell; +use std::rc::{Rc, Weak}; + +use crate::broadcast::{Broadcast, Subscription}; +use crate::machine::{Machine, Sender}; +use crate::params::View; +use crate::traits::Types; + +/// What a connect() reads: the live machine plus the consumer props. +pub struct ConnectSnapshot<'a, T: Types, P> { + pub view: View<'a, T>, + pub props: &'a P, + pub sender: Sender, +} + +type SetupReaction = Rc, Weak>) -> Subscription>; + +/// "A selected machine value" → "a consumer callback", wired on start, torn down on stop. +pub struct Reaction { + setup: SetupReaction, +} + +impl Reaction { + pub fn new( + selector: impl Fn(&View<'_, T>) -> V + 'static, + callback: impl Fn(&V, &P) + 'static, + ) -> Self { + let selector = Rc::new(selector); + let callback = Rc::new(callback); + Self { + setup: Rc::new(move |machine: &Machine, props: Weak>| { + let selector = selector.clone(); + let callback = callback.clone(); + machine + .select(move |v| selector(v)) + .subscribe(move |value| { + if let Some(props) = props.upgrade() { + callback(value, &props.borrow()); + } + }) + }), + } + } +} + +type ConnectFn = Box) -> A>; + +struct ConnectorInner { + machine: Machine, + connect: ConnectFn, + props: Rc>, + cached: RefCell>>, + bus: Rc, + reactions: Vec>, + reaction_subs: RefCell>, + machine_subs: RefCell>, +} + +/// The view boundary: a memoized view surface over a machine + props. Passive — the +/// bridge owns the machine's lifecycle. +pub struct Connector(Rc>); + +impl Connector { + pub fn new( + machine: &Machine, + connect: impl Fn(&ConnectSnapshot<'_, T, P>) -> A + 'static, + props: P, + reactions: Vec>, + ) -> Self { + let inner = Rc::new(ConnectorInner { + machine: machine.clone(), + connect: Box::new(connect), + props: Rc::new(RefCell::new(props)), + cached: RefCell::new(None), + bus: Broadcast::new(), + reactions, + reaction_subs: RefCell::new(Vec::new()), + machine_subs: RefCell::new(Vec::new()), + }); + let weak = Rc::downgrade(&inner); + let wake = machine.subscribe({ + let weak = weak.clone(); + move || { + if let Some(c) = weak.upgrade() { + c.wake(); + } + } + }); + let on_start = machine.on_start({ + let weak = weak.clone(); + move || { + let Some(c) = weak.upgrade() else { return }; + let subs: Vec = c + .reactions + .iter() + .map(|r| (r.setup)(&c.machine, Rc::downgrade(&c.props))) + .collect(); + c.reaction_subs.borrow_mut().extend(subs); + } + }); + let on_stop = machine.on_stop(move || { + if let Some(c) = weak.upgrade() { + for s in c.reaction_subs.borrow_mut().drain(..) { + s.unsubscribe(); + } + } + }); + inner + .machine_subs + .borrow_mut() + .extend([wake, on_start, on_stop]); + Self(inner) + } + + /// The memoized connect() output: the same `Rc` while machine and props are unchanged. + pub fn snapshot(&self) -> Rc { + if let Some(cached) = self.0.cached.borrow().as_ref() { + return cached.clone(); + } + let api = { + let inner = &self.0.machine.0; + let core = inner.core.borrow(); + let props = self.0.props.borrow(); + let snap = ConnectSnapshot { + view: View { core: &core, inner }, + props: &*props, + sender: self.0.machine.sender(), + }; + Rc::new((self.0.connect)(&snap)) + }; + *self.0.cached.borrow_mut() = Some(api.clone()); + api + } + + pub fn subscribe(&self, listener: impl Fn() + 'static) -> Subscription { + self.0.bus.add(Rc::new(listener)) + } + + /// Update the consumer props. Equal props change nothing. + pub fn set_props(&self, props: P) { + if *self.0.props.borrow() == props { + return; + } + *self.0.props.borrow_mut() = props; + self.0.wake(); + } + + /// Detach from the machine (only needed when discarding the connector on its own). + pub fn destroy(&self) { + for s in self.0.machine_subs.borrow_mut().drain(..) { + s.unsubscribe(); + } + for s in self.0.reaction_subs.borrow_mut().drain(..) { + s.unsubscribe(); + } + self.0.bus.clear(); + } +} + +impl ConnectorInner { + fn wake(&self) { + *self.cached.borrow_mut() = None; + self.bus.notify(); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..1800700 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,61 @@ +//! `dunky-core` — the Rust port of `@dunky.dev/state-machine`. +//! +//! The behavior contract is `packages/core/SPEC.md`; this crate implements the same +//! engine (flat states, run-to-completion, guards, actions, effects, `after` timers, +//! computed data, watchers, selections, connector, composition, store) for Rust hosts and +//! for the wasm / native bindings. +//! +//! Rust-specific shape: +//! - **Typed**: states, events and context are Rust types (`#[derive(State, Event, Context)]`). +//! - **Sans-IO timers**: the core owns no clock. `after` emits [`Command`]s; the host runs +//! them and calls [`Machine::fire_timer`]. +//! - **Change mask**: [`Machine::take_changes`] reports which fields changed, so a binding +//! can mirror only those into another runtime. + +// Lets the derive macros (which emit `::dunky_core::...`) work inside this crate too. +extern crate self as dunky_core; + +mod broadcast; +mod compose; +mod computed; +mod config; +mod connector; +mod machine; +mod params; +pub mod protocol; +mod selection; +mod store; +mod timers; +mod traits; + +pub mod testing; + +pub use broadcast::Subscription; +pub use compose::{Combined, Composition, Member}; +pub use computed::{ComputedKey, ComputedParams}; +pub use config::{ + Action, ActionFn, Branch, Cleanup, Config, ConfigBuilder, Delay, Effect, Guard, StateBuilder, + TransitionBuilder, +}; +pub use connector::{ConnectSnapshot, Connector, Reaction}; +pub use machine::{Changes, Machine, Sender}; +pub use params::{ActionParams, GuardParams, View}; +pub use selection::Selection; +pub use store::Store; +pub use timers::{Command, TimerId}; +pub use traits::{Context, EventEnum, Field, StateEnum, Types}; +#[cfg(feature = "serde")] +pub use traits::{DeserializeEvent, SerializeFields}; + +#[cfg(feature = "derive")] +pub use dunky_macros::{Context, Event, State}; + +/// The `machine.init` marker name: what a boot effect or a data-reaction sees as its +/// event type (`ActionParams::event()` returns `None` for it). +pub const MACHINE_INIT: &str = "machine.init"; + +#[doc(hidden)] +pub mod __private { + #[cfg(feature = "serde")] + pub use serde; +} diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs new file mode 100644 index 0000000..5ab4e93 --- /dev/null +++ b/crates/core/src/machine.rs @@ -0,0 +1,722 @@ +//! The live service: run-to-completion queue, transition resolution, lifecycle. +//! +//! Borrow discipline (the Rust shape of the TS engine's re-entrancy rules): +//! - Actions, guards, effects, delays and computed definitions run while the core is +//! borrowed; they get params instead of the machine handle. +//! - Observers (subscribe, selections, lifecycle listeners, effect cleanups) run with NO +//! borrow held, so they may read the machine, send to it, or patch it. Sends made while +//! a flush is in progress are queued and run after the current item, never interleaved. + +use std::any::Any; +use std::cell::{Cell, Ref, RefCell}; +use std::collections::VecDeque; +use std::rc::{Rc, Weak}; + +use crate::broadcast::{Broadcast, Subscription}; +use crate::computed::{self, ComputedKey, ComputedRuntime}; +use crate::config::{ + Action, Config, Delay, Effect, Entry, Guard, Transition, WatchSource, missing, +}; +use crate::params::{ActionParams, GuardParams, View}; +use crate::selection::Selection; +use crate::timers::{Command, TimerId}; +use crate::traits::{Context, EventEnum, StateEnum, Types}; + +/// Dev-only runaway guard for one flush. A real chain is a handful of items; thousands +/// means a feedback loop (a watcher writing what it watches, actions sending in a cycle). +const MAX_FLUSH: u32 = 10_000; + +pub(crate) enum Item { + Event(T::Event), + After { + state: T::State, + index: usize, + event: Option, + generation: u64, + }, + Watch { + index: usize, + }, +} + +struct TimerRecord { + id: TimerId, + state: T::State, + index: usize, + event: Option, + generation: u64, +} + +pub(crate) struct Core { + pub(crate) ctx: T::Context, + pub(crate) state: T::State, + pub(crate) running: bool, + /// Bumped on every state entry; a timer scheduled under another generation is stale. + entry_counter: u64, + state_cleanups: Vec, + timers: Vec>, + next_timer_id: u32, + commands: Vec, + pub(crate) computed: ComputedRuntime, + /// Machine-wide change counter; fields and the state are stamped with it when they change. + pub(crate) tick: u64, + pub(crate) field_changed_at: Vec, + pub(crate) state_changed_at: u64, + /// Effective changes made inside the current borrow; drained into notifications. + pending_notifies: u32, + /// Changes since the last `take_changes()`, for bindings. + changed_fields: u64, + changed_state: bool, +} + +impl Core { + /// Apply `patch`; return the mask of the fields that actually changed. + pub(crate) fn patch(&mut self, patch: ::Patch) -> u64 { + let mask = self.ctx.apply(patch); + if mask == 0 { + return 0; + } + self.tick += 1; + let mut bits = mask; + while bits != 0 { + let i = bits.trailing_zeros() as usize; + self.field_changed_at[i] = self.tick; + bits &= bits - 1; + } + self.changed_fields |= mask; + self.pending_notifies += 1; + mask + } + + fn set_state(&mut self, next: T::State) { + self.state = next; + self.tick += 1; + self.state_changed_at = self.tick; + self.changed_state = true; + self.pending_notifies += 1; + } +} + +pub(crate) struct Inner { + pub(crate) config: Config, + pub(crate) core: RefCell>, + pub(crate) queue: RefCell>>, + flushing: Cell, + pub(crate) bus: Rc, + /// Per watch: the change stamp a computed watch last saw (unused by field watches). + watch_stamps: RefCell>, + lifecycle: Rc, + stop_listeners: Rc, + pub(crate) weak_self: Weak>, +} + +/// A live machine service. Built stopped; [`Machine::start`] boots effects and watchers, +/// [`Machine::stop`] runs their cleanups. Cloning shares the same service. +pub struct Machine(pub(crate) Rc>); + +impl Clone for Machine { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +/// A handle that sends events to a machine without keeping it alive. +pub struct Sender(pub(crate) Weak>); + +impl Clone for Sender { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Sender { + /// Send `event` if the machine still exists. Returns whether it did. + pub fn send(&self, event: T::Event) -> bool { + match self.0.upgrade() { + Some(inner) => { + inner.queue.borrow_mut().push_back(Item::Event(event)); + inner.flush(); + true + } + None => false, + } + } +} + +/// What changed since the last [`Machine::take_changes`]: a bit per context field, plus the state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Changes { + pub state: bool, + pub fields: u64, +} + +impl Changes { + pub fn any(self) -> bool { + self.state || self.fields != 0 + } +} + +impl Machine { + /// Build a stopped service from `config`, with its own copy of the seed context. + pub fn new(config: &Config) -> Self { + let fields = ::FIELDS.len(); + assert!( + fields <= 64, + "[machine] a context supports at most 64 fields" + ); + let core = Core { + ctx: config.0.context.clone(), + state: config.0.initial, + running: false, + entry_counter: 0, + state_cleanups: Vec::new(), + timers: Vec::new(), + next_timer_id: 1, + commands: Vec::new(), + computed: ComputedRuntime::new(config.0.computed.len()), + tick: 0, + field_changed_at: vec![0; fields], + state_changed_at: 0, + pending_notifies: 0, + changed_fields: 0, + changed_state: false, + }; + Self(Rc::new_cyclic(|weak| Inner { + config: config.clone(), + core: RefCell::new(core), + queue: RefCell::new(VecDeque::new()), + flushing: Cell::new(false), + bus: Broadcast::new(), + watch_stamps: RefCell::new(vec![0; config.0.watch.len()]), + lifecycle: Broadcast::new(), + stop_listeners: Broadcast::new(), + weak_self: weak.clone(), + })) + } + + /// Like [`Machine::new`], but seeds the context from `context` instead of the config's. + pub fn with_context(config: &Config, context: T::Context) -> Self { + let m = Self::new(config); + m.0.core.borrow_mut().ctx = context; + m + } + + pub fn config(&self) -> &Config { + &self.0.config + } + + pub fn state(&self) -> T::State { + self.0.core.borrow().state + } + + /// Borrow the live context. Do not hold the guard across `send`. + pub fn context(&self) -> Ref<'_, T::Context> { + Ref::map(self.0.core.borrow(), |c| &c.ctx) + } + + pub fn computed(&self, key: ComputedKey) -> Rc { + let core = self.0.core.borrow(); + computed::downcast(computed::get(&core, &self.0, key.id()), &self.0, key.id()) + } + + /// The type-erased value of computed `id` (for bindings that know the type elsewhere). + pub fn computed_any(&self, id: usize) -> Rc { + let core = self.0.core.borrow(); + computed::get(&core, &self.0, id) + } + + /// A version stamp for computed `id`: it changes exactly when the value changes. + pub fn computed_version(&self, id: usize) -> u64 { + let core = self.0.core.borrow(); + computed::changed_at(&core, &self.0, id) + } + + pub fn matches(&self, state: T::State) -> bool { + self.state() == state + } + + pub fn has_tag(&self, tag: &str) -> bool { + let state = self.state(); + self.0.config.0.states[state.index()].tags.contains(&tag) + } + + pub fn is_running(&self) -> bool { + self.0.core.borrow().running + } + + /// Dispatch an event. It runs to completion before the next queued item. + pub fn send(&self, event: T::Event) { + self.0.queue.borrow_mut().push_back(Item::Event(event)); + self.0.flush(); + } + + /// Shallow-merge `patch` into the context (bridges use this to refresh prop-seeded + /// context). Observers are notified only if a field actually changed. + pub fn set_context(&self, patch: ::Patch) { + self.0.with_flush(|inner| { + { + let mut core = inner.core.borrow_mut(); + let fields = core.patch(patch); + if fields != 0 { + inner.detect_watched(&core, fields); + } + } + inner.drain_notifies(); + }); + } + + /// Coarse subscription: fires on any later change (state or context), never on subscribe. + pub fn subscribe(&self, listener: impl Fn() + 'static) -> Subscription { + self.0.bus.add(Rc::new(listener)) + } + + /// A value-deduped view of anything the machine exposes. + pub fn select( + &self, + selector: impl Fn(&View<'_, T>) -> V + 'static, + ) -> Selection { + Selection::new(self.clone(), Rc::new(selector)) + } + + pub fn select_state(&self) -> Selection { + self.select(|v| v.state()) + } + + pub fn select_computed(&self, key: ComputedKey) -> Selection> { + self.select(move |v| v.computed(key)) + } + + /// Boot the watchers and the CURRENT state's effects. No-op if already running. + pub fn start(&self) { + let inner = &*self.0; + let started = inner.with_flush(|inner| { + let state = { + let mut core = inner.core.borrow_mut(); + if core.running { + return false; + } + core.running = true; + core.state + }; + inner.start_watchers(); + inner.start_effects(state, None); + true + }); + if started { + inner.with_flush(|inner| inner.lifecycle.notify()); + } + } + + /// Run every active effect's cleanup and dispose the watchers. Keeps state and context. + pub fn stop(&self) { + let inner = &*self.0; + let stopped = inner.with_flush(|inner| { + { + let mut core = inner.core.borrow_mut(); + if !core.running { + return false; + } + core.running = false; + } + // Watchers need no teardown: they detect only while running, and a run + // still queued is dropped when it comes up. + inner.stop_effects(); + true + }); + if stopped { + inner.with_flush(|inner| inner.stop_listeners.notify()); + } + } + + /// Fires on every `start()` — immediately if already running. + pub fn on_start(&self, listener: impl Fn() + 'static) -> Subscription { + let listener: Rc = Rc::new(listener); + let sub = self.0.lifecycle.add(listener.clone()); + if self.is_running() { + listener(); + } + sub + } + + /// Fires on every `stop()`. + pub fn on_stop(&self, listener: impl Fn() + 'static) -> Subscription { + self.0.stop_listeners.add(Rc::new(listener)) + } + + /// Host callback: the timer `id` (from a [`Command::StartTimer`]) came due. + pub fn fire_timer(&self, id: TimerId) { + let item = { + let mut core = self.0.core.borrow_mut(); + match core.timers.iter().position(|t| t.id == id) { + Some(pos) => { + let t = core.timers.swap_remove(pos); + Item::After { + state: t.state, + index: t.index, + event: t.event, + generation: t.generation, + } + } + None => return, // cancelled or already fired + } + }; + self.0.queue.borrow_mut().push_back(item); + self.0.flush(); + } + + /// Take the timer commands emitted since the last call. A host runs them. + pub fn take_commands(&self) -> Vec { + std::mem::take(&mut self.0.core.borrow_mut().commands) + } + + pub fn has_commands(&self) -> bool { + !self.0.core.borrow().commands.is_empty() + } + + /// Take what changed since the last call (for bindings that mirror state elsewhere). + pub fn take_changes(&self) -> Changes { + let mut core = self.0.core.borrow_mut(); + let changes = Changes { + state: core.changed_state, + fields: core.changed_fields, + }; + core.changed_state = false; + core.changed_fields = 0; + changes + } + + pub fn sender(&self) -> Sender { + Sender(Rc::downgrade(&self.0)) + } + + /// Number of coarse subscribers (diagnostics). + pub fn subscriber_count(&self) -> usize { + self.0.bus.len() + } +} + +impl Inner { + /// Run `f` as part of a flush: re-entrant sends queue instead of nesting, and the queue + /// drains after `f` unless an outer flush owns it. + fn with_flush(&self, f: impl FnOnce(&Self) -> R) -> R { + let outer = self.flushing.replace(true); + let reset = FlushReset(&self.flushing, outer); + let result = f(self); + drop(reset); + if !outer { + self.flush(); + } + result + } + + pub(crate) fn flush(&self) { + if self.flushing.get() { + return; + } + self.flushing.set(true); + let _reset = FlushReset(&self.flushing, false); + let mut ticks = 0u32; + loop { + let item = self.queue.borrow_mut().pop_front(); + let Some(item) = item else { break }; + if cfg!(debug_assertions) { + ticks += 1; + if ticks > MAX_FLUSH { + self.queue.borrow_mut().clear(); + panic!( + "[machine] one flush exceeded {MAX_FLUSH} steps — feedback loop \ + (e.g. a watcher writing the field it watches, or actions sending in a cycle)" + ); + } + } + self.process(item); + } + } + + fn process(&self, item: Item) { + match item { + Item::Event(event) => { + let kind = ::kind_index(event.kind()); + let state = self.core.borrow().state; + let Some(entry) = self.config.lookup(state, kind) else { + return; + }; + if let Some(t) = self.resolve(entry, Some(&event)) { + self.apply(t, Some(&event)); + } + } + Item::After { + state, + index, + event, + generation, + } => { + { + let core = self.core.borrow(); + if !core.running || core.entry_counter != generation { + return; + } + } + let entry = &self.config.0.states[state.index()].after[index].1; + if let Some(t) = self.resolve(entry, event.as_ref()) { + self.apply(t, event.as_ref()); + } + } + Item::Watch { index } => { + if self.core.borrow().running { + self.run_actions(&self.config.0.watch[index].actions, None); + } + } + } + } + + /// The first candidate whose guard passes. + fn resolve<'c>( + &self, + entry: &'c Entry, + event: Option<&T::Event>, + ) -> Option<&'c Transition> { + let core = self.core.borrow(); + let params = GuardParams { + core: &core, + inner: self, + event, + }; + entry + .iter() + .find(|t| t.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) + } + + fn apply(&self, t: &Transition, event: Option<&T::Event>) { + let (cur, running) = { + let core = self.core.borrow(); + (core.state, core.running) + }; + let next = t.target.unwrap_or(cur); + let leaving = next != cur; + let states = &self.config.0.states; + if leaving { + if running { + self.stop_effects(); + } + self.run_actions(&states[cur.index()].exit, event); + } + self.run_actions(&t.actions, event); + if leaving { + { + let mut core = self.core.borrow_mut(); + core.set_state(next); + self.detect_watched(&core, 0); + } + self.drain_notifies(); + self.run_actions(&states[next.index()].entry, event); + if self.core.borrow().running { + self.start_effects(next, event); + } + } + } + + pub(crate) fn run_actions(&self, actions: &[Action], event: Option<&T::Event>) { + for action in actions { + self.run_action(action, event); + } + } + + fn run_action(&self, action: &Action, event: Option<&T::Event>) { + match action { + Action::Fn(f) => self.call_action(f, event), + Action::Named(n) => match &n.imp { + Some(f) => self.call_action(f, event), + None => missing("action", n.name), + }, + Action::OneOf(branches) => { + let picked = { + let core = self.core.borrow(); + let params = GuardParams { + core: &core, + inner: self, + event, + }; + branches + .iter() + .find(|b| b.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) + }; + if let Some(branch) = picked { + self.run_actions(&branch.actions, event); + } + } + } + } + + fn call_action(&self, f: &crate::config::ActionFn, event: Option<&T::Event>) { + { + let mut core = self.core.borrow_mut(); + let mut params = ActionParams { + core: &mut core, + inner: self, + event, + }; + f(&mut params); + } + self.drain_notifies(); + } + + /// Deliver the notifications owed by the changes made in the last borrow — once per + /// effective change, with no borrow held so observers can read and send. + pub(crate) fn drain_notifies(&self) { + let n = std::mem::take(&mut self.core.borrow_mut().pending_notifies); + for _ in 0..n { + self.bus.notify(); + } + } + + fn start_effects(&self, state: T::State, event: Option<&T::Event>) { + let node = &self.config.0.states[state.index()]; + { + let mut core = self.core.borrow_mut(); + core.entry_counter += 1; + let generation = core.entry_counter; + for (index, (delay, _)) in node.after.iter().enumerate() { + let ms = match delay { + Delay::Ms(ms) => *ms, + Delay::Named(n) => match &n.imp { + Some(f) => f(&GuardParams { + core: &core, + inner: self, + event, + }), + None => { + missing("delay", n.name); + 0 + } + }, + }; + let id = core.next_timer_id; + core.next_timer_id = core.next_timer_id.wrapping_add(1).max(1); + core.timers.push(TimerRecord { + id, + state, + index, + event: event.cloned(), + generation, + }); + core.commands.push(Command::StartTimer { id, ms }); + } + } + for effect in &node.effects { + let f = match effect { + Effect::Fn(f) => f, + Effect::Named(n) => match &n.imp { + Some(f) => f, + None => { + missing("effect", n.name); + continue; + } + }, + }; + let cleanup = { + let mut core = self.core.borrow_mut(); + let mut params = ActionParams { + core: &mut core, + inner: self, + event, + }; + f(&mut params) + }; + if let Some(cleanup) = cleanup { + self.core.borrow_mut().state_cleanups.push(cleanup); + } + self.drain_notifies(); + } + } + + /// Cancel the state's timers and run its effect cleanups. A panicking cleanup must not + /// leak the others: finish the pass, then resume the first panic. + fn stop_effects(&self) { + let cleanups = { + let mut core = self.core.borrow_mut(); + let core = &mut *core; + for t in core.timers.drain(..) { + core.commands.push(Command::CancelTimer { id: t.id }); + } + std::mem::take(&mut core.state_cleanups) + }; + let mut first_panic = None; + for cleanup in cleanups { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cleanup)); + if let Err(payload) = result { + first_panic.get_or_insert(payload); + } + } + if let Some(payload) = first_panic { + std::panic::resume_unwind(payload); + } + } + + /// Seed each computed watch with its value's change stamp. A field watch needs no + /// seed: a write's change mask names the fields it changed. + fn start_watchers(&self) { + let core = self.core.borrow(); + let mut stamps = self.watch_stamps.borrow_mut(); + for (index, watch) in self.config.0.watch.iter().enumerate() { + if let WatchSource::Computed(id) = watch.source { + stamps[index] = computed::changed_at(&core, self, id); + } + } + } + + /// Watchers observe a change as it happens, like the TS selections they mirror: each + /// write of a watched field, and each change of a watched computed value, queues that + /// watch's run right away, in order with the sends around it (its actions are still + /// deferred). `fields` is the write's change mask; a state switch passes 0, since + /// only a computed value can observe it. + pub(crate) fn detect_watched(&self, core: &Core, fields: u64) { + if !core.running { + return; + } + for (index, watch) in self.config.0.watch.iter().enumerate() { + let changed = match watch.source { + WatchSource::Field(field) => fields & (1u64 << field) != 0, + WatchSource::Computed(id) => { + // The stamp moves exactly when the value changes (by `eq`). + let now = computed::changed_at(core, self, id); + let mut stamps = self.watch_stamps.borrow_mut(); + let changed = stamps[index] != now; + stamps[index] = now; + changed + } + }; + if changed { + self.queue.borrow_mut().push_back(Item::Watch { index }); + } + } + } +} + +pub(crate) fn eval_guard(guard: &Guard, params: &GuardParams<'_, T>) -> bool { + match guard { + Guard::Fn(f) => f(params), + Guard::Named(n) => match n + .imp + .as_ref() + .or_else(|| params.inner.config.0.guards.get(n.name)) + { + Some(f) => f(params), + None => { + missing("guard", n.name); + false + } + }, + Guard::And(gs) => gs.iter().all(|g| eval_guard(g, params)), + Guard::Or(gs) => gs.iter().any(|g| eval_guard(g, params)), + Guard::Not(g) => !eval_guard(g, params), + } +} + +struct FlushReset<'a>(&'a Cell, bool); + +impl Drop for FlushReset<'_> { + fn drop(&mut self) { + self.0.set(self.1); + } +} diff --git a/crates/core/src/params.rs b/crates/core/src/params.rs new file mode 100644 index 0000000..5ed39c7 --- /dev/null +++ b/crates/core/src/params.rs @@ -0,0 +1,110 @@ +use std::rc::Rc; + +use crate::computed::{self, ComputedKey}; +use crate::config::Guard; +use crate::machine::{Core, Inner, Item, Sender}; +use crate::traits::{StateEnum, Types}; + +/// Everything an action (and an effect) can read and do. +pub struct ActionParams<'a, T: Types> { + pub(crate) core: &'a mut Core, + pub(crate) inner: &'a Inner, + pub(crate) event: Option<&'a T::Event>, +} + +impl<'a, T: Types> ActionParams<'a, T> { + pub fn context(&self) -> &T::Context { + &self.core.ctx + } + /// The event being processed. `None` is the `machine.init` marker: a boot effect on + /// `start()`, or a data-reaction (a data change is not a domain event). + pub fn event(&self) -> Option<&'a T::Event> { + self.event + } + pub fn state(&self) -> T::State { + self.core.state + } + /// Shallow-merge `patch` into the context. Observers are notified only if a field + /// actually changed. + pub fn set_context(&mut self, patch: ::Patch) { + let fields = self.core.patch(patch); + if fields != 0 { + self.inner.detect_watched(self.core, fields); + } + } + /// Dispatch another event. It is queued and runs after the current one completes. + pub fn send(&mut self, event: T::Event) { + self.inner.queue.borrow_mut().push_back(Item::Event(event)); + } + pub fn computed(&self, key: ComputedKey) -> Rc { + computed::downcast( + computed::get(self.core, self.inner, key.id()), + self.inner, + key.id(), + ) + } + /// A handle that can send events later (e.g. from a store subscription in an effect). + pub fn sender(&self) -> Sender { + Sender(self.inner.weak_self.clone()) + } +} + +/// Everything a guard (and a named delay) can read. +pub struct GuardParams<'a, T: Types> { + pub(crate) core: &'a Core, + pub(crate) inner: &'a Inner, + pub(crate) event: Option<&'a T::Event>, +} + +impl<'a, T: Types> GuardParams<'a, T> { + pub fn context(&self) -> &'a T::Context { + &self.core.ctx + } + pub fn event(&self) -> Option<&'a T::Event> { + self.event + } + pub fn state(&self) -> T::State { + self.core.state + } + pub fn computed(&self, key: ComputedKey) -> Rc { + computed::downcast( + computed::get(self.core, self.inner, key.id()), + self.inner, + key.id(), + ) + } + /// Evaluate another guard against these same params (for hand-written combinators). + pub fn check(&self, guard: &Guard) -> bool { + crate::machine::eval_guard(guard, self) + } +} + +/// A read-only view of the machine, handed to selectors. +pub struct View<'a, T: Types> { + pub(crate) core: &'a Core, + pub(crate) inner: &'a Inner, +} + +impl<'a, T: Types> View<'a, T> { + pub fn state(&self) -> T::State { + self.core.state + } + pub fn context(&self) -> &'a T::Context { + &self.core.ctx + } + pub fn computed(&self, key: ComputedKey) -> Rc { + computed::downcast( + computed::get(self.core, self.inner, key.id()), + self.inner, + key.id(), + ) + } + pub fn matches(&self, state: T::State) -> bool { + self.core.state == state + } + pub fn has_tag(&self, tag: &str) -> bool { + self.inner.config.0.states[self.core.state.index()] + .tags + .contains(&tag) + } +} diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs new file mode 100644 index 0000000..d882887 --- /dev/null +++ b/crates/core/src/protocol.rs @@ -0,0 +1,64 @@ +//! The handle protocol the bindings speak (wasm, React Native over JSI): one change mask +//! per call, and timer commands as flat numbers. The TS adapter decodes every binding +//! with the same code, so the layout lives here, once. + +use crate::machine::Machine; +use crate::timers::Command; +use crate::traits::Types; + +/// Change-mask bit: the state changed. +pub const STATE_BIT: u32 = 1 << 0; +/// Change-mask bit: timer commands are waiting. +pub const COMMANDS_BIT: u32 = 1 << 1; +/// Context field `i` maps to bit `FIELD_SHIFT + i`. Fields from index [`HIGH_FIELD`] up +/// share the top bit: when it is set, re-read all of them. +pub const FIELD_SHIFT: u32 = 2; +pub const HIGH_FIELD: usize = (32 - FIELD_SHIFT as usize) - 1; +/// Timer command opcodes in [`encode_commands`]. +pub const OP_START: u32 = 1; +pub const OP_CANCEL: u32 = 2; + +/// Take what changed since the last call and pack it into the change mask. +pub fn take_change_mask(machine: &Machine) -> u32 { + let changes = machine.take_changes(); + let mut mask = if changes.state { STATE_BIT } else { 0 }; + if machine.has_commands() { + mask |= COMMANDS_BIT; + } + let mut fields = changes.fields; + while fields != 0 { + let i = fields.trailing_zeros() as usize; + mask |= 1 << (FIELD_SHIFT as usize + i.min(HIGH_FIELD)); + fields &= fields - 1; + } + mask +} + +/// Timer commands as flat `[op, id, ms]` triples (`ms` is 0 for a cancel). +pub fn encode_commands(commands: &[Command]) -> Vec { + let mut out = Vec::with_capacity(commands.len() * 3); + for c in commands { + match *c { + Command::StartTimer { id, ms } => out.extend([OP_START, id, ms]), + Command::CancelTimer { id } => out.extend([OP_CANCEL, id, 0]), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commands_encode_as_op_id_ms_triples() { + let commands = [ + Command::StartTimer { id: 7, ms: 250 }, + Command::CancelTimer { id: 7 }, + ]; + assert_eq!( + encode_commands(&commands), + [OP_START, 7, 250, OP_CANCEL, 7, 0] + ); + } +} diff --git a/crates/core/src/selection.rs b/crates/core/src/selection.rs new file mode 100644 index 0000000..a5d246a --- /dev/null +++ b/crates/core/src/selection.rs @@ -0,0 +1,87 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::broadcast::Subscription; +use crate::machine::Machine; +use crate::params::View; +use crate::traits::Types; + +type Selector = Rc) -> V>; + +/// A narrowed, value-deduped view of a machine. +pub struct Selection { + machine: Machine, + selector: Selector, +} + +impl Clone for Selection { + fn clone(&self) -> Self { + Self { + machine: self.machine.clone(), + selector: self.selector.clone(), + } + } +} + +impl Selection { + pub(crate) fn new(machine: Machine, selector: Selector) -> Self { + Self { machine, selector } + } + + /// The current selected value, evaluated on read. + pub fn value(&self) -> V { + let inner = &self.machine.0; + let core = inner.core.borrow(); + (self.selector)(&View { core: &core, inner }) + } + + /// Fire `listener(value)` only when the selected value changes (`==`). Never fires on + /// subscribe. + pub fn subscribe(&self, listener: impl Fn(&V) + 'static) -> Subscription + where + V: PartialEq, + { + self.subscribe_with(listener, |a: &V, b: &V| a == b) + } + + /// Like [`Selection::subscribe`] with a custom equality. + pub fn subscribe_with( + &self, + listener: impl Fn(&V) + 'static, + equals: impl Fn(&V, &V) -> bool + 'static, + ) -> Subscription { + let inner = &self.machine.0; + // Seed at subscribe. If the machine is mid-action (core borrowed), seed on the + // first wake instead. + let seed = inner + .core + .try_borrow() + .ok() + .map(|core| (self.selector)(&View { core: &core, inner })); + // `Rc` so the listener runs with no borrow of `prev` held: it may patch the machine, + // which notifies (and re-enters this wake) before the listener returns. + let prev: RefCell>> = RefCell::new(seed.map(Rc::new)); + let selector = self.selector.clone(); + let weak = Rc::downgrade(inner); + inner.bus.add(Rc::new(move || { + let Some(inner) = weak.upgrade() else { return }; + let next = { + let core = inner.core.borrow(); + selector(&View { + core: &core, + inner: &inner, + }) + }; + let seeded = match prev.borrow().as_deref() { + Some(old) if equals(old, &next) => return, + Some(_) => true, + None => false, + }; + let next = Rc::new(next); + *prev.borrow_mut() = Some(next.clone()); + if seeded { + listener(&next); + } + })) + } +} diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs new file mode 100644 index 0000000..665e37e --- /dev/null +++ b/crates/core/src/store.rs @@ -0,0 +1,66 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::broadcast::{Broadcast, Subscription}; +use crate::traits::Context; + +/// A tiny reactive cell for state shared between machine instances ("only one tooltip +/// open at a time"). Updates shallow-merge and notify only on an actual change. +pub struct Store { + state: Rc>>, + bus: Rc, +} + +impl Clone for Store { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + bus: self.bus.clone(), + } + } +} + +impl Store { + pub fn new(initial: S) -> Self { + Self { + state: Rc::new(RefCell::new(Rc::new(initial))), + bus: Broadcast::new(), + } + } + + /// The current value. Each effective update yields a fresh `Rc`, so identity change + /// is the change signal. + pub fn get(&self) -> Rc { + self.state.borrow().clone() + } + + pub fn set(&self, patch: S::Patch) { + let changed = { + let mut state = self.state.borrow_mut(); + let mut next = (**state).clone(); + if next.apply(patch) == 0 { + false + } else { + *state = Rc::new(next); + true + } + }; + if changed { + self.bus.notify(); + } + } + + pub fn update(&self, f: impl FnOnce(&S) -> S::Patch) { + let patch = f(&self.get()); + self.set(patch); + } + + /// Fires on every later change (not on subscribe). + pub fn subscribe(&self, listener: impl Fn(&Rc) + 'static) -> Subscription { + let state = self.state.clone(); + self.bus.add(Rc::new(move || { + let value = state.borrow().clone(); + listener(&value); + })) + } +} diff --git a/crates/core/src/testing.rs b/crates/core/src/testing.rs new file mode 100644 index 0000000..0c3c20f --- /dev/null +++ b/crates/core/src/testing.rs @@ -0,0 +1,68 @@ +//! Test helpers: a virtual clock that plays the host for `after` timers. + +use crate::machine::Machine; +use crate::timers::{Command, TimerId}; +use crate::traits::Types; + +/// A deterministic host clock for one machine: it runs the machine's timer commands and +/// fires due timers as virtual time advances (like fake timers in the TS tests). +pub struct Clock { + machine: Machine, + now: u64, + seq: u64, + pending: Vec<(u64, u64, TimerId)>, +} + +impl Clock { + pub fn new(machine: &Machine) -> Self { + Self { + machine: machine.clone(), + now: 0, + seq: 0, + pending: Vec::new(), + } + } + + pub fn now(&self) -> u64 { + self.now + } + + /// Timers currently scheduled (as of the last sync). + pub fn pending(&self) -> usize { + self.pending.len() + } + + /// Apply the commands the machine emitted since the last sync. + pub fn sync(&mut self) { + for cmd in self.machine.take_commands() { + match cmd { + Command::StartTimer { id, ms } => { + self.seq += 1; + self.pending.push((self.now + u64::from(ms), self.seq, id)); + } + Command::CancelTimer { id } => self.pending.retain(|(_, _, t)| *t != id), + } + } + } + + /// Advance virtual time by `ms`, firing every timer that comes due, in order. + pub fn advance(&mut self, ms: u64) { + self.sync(); + let target = self.now + ms; + loop { + let next = self + .pending + .iter() + .enumerate() + .filter(|(_, (due, _, _))| *due <= target) + .min_by_key(|(_, (due, seq, _))| (*due, *seq)) + .map(|(i, _)| i); + let Some(i) = next else { break }; + let (due, _, id) = self.pending.remove(i); + self.now = due; + self.machine.fire_timer(id); + self.sync(); + } + self.now = target; + } +} diff --git a/crates/core/src/timers.rs b/crates/core/src/timers.rs new file mode 100644 index 0000000..cd201f5 --- /dev/null +++ b/crates/core/src/timers.rs @@ -0,0 +1,11 @@ +/// Identifies one scheduled `after` timer. +pub type TimerId = u32; + +/// What the sans-IO core asks its host to do. The core owns no clock: a host (a JS +/// adapter, a native event loop, a test clock) runs these and calls +/// `Machine::fire_timer(id)` when a timer comes due. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Command { + StartTimer { id: TimerId, ms: u32 }, + CancelTimer { id: TimerId }, +} diff --git a/crates/core/src/traits.rs b/crates/core/src/traits.rs new file mode 100644 index 0000000..5b0e21f --- /dev/null +++ b/crates/core/src/traits.rs @@ -0,0 +1,141 @@ +use core::cell::Cell; +use core::fmt::Debug; +use core::marker::PhantomData; + +/// A machine's finite set of states — a fieldless enum. Derive it with `#[derive(State)]`. +pub trait StateEnum: Copy + Eq + Debug + 'static { + /// Names in variant order. These are the names the JS side sees. + const NAMES: &'static [&'static str]; + fn index(self) -> usize; + fn from_index(index: usize) -> Self; + fn name(self) -> &'static str { + Self::NAMES[self.index()] + } +} + +/// A machine's events. Derive it with `#[derive(Event)]`, which also generates the +/// fieldless `Kind` enum that transitions are keyed by. +pub trait EventEnum: Clone + Debug + 'static { + /// One variant per event type, without payload. + type Kind: Copy + Eq + Debug + 'static; + /// Event type names in variant order (`"query.set"` …). JS sends `{ type: }`. + const KIND_NAMES: &'static [&'static str]; + fn kind(&self) -> Self::Kind; + fn kind_index(kind: Self::Kind) -> usize; + fn kind_from_index(index: usize) -> Self::Kind; + /// Build a payload-less event from its kind. `None` for variants that carry data. + fn from_kind(kind: Self::Kind) -> Option; + fn type_name(&self) -> &'static str { + Self::KIND_NAMES[Self::kind_index(self.kind())] + } +} + +/// The machine context: one struct, patched in place. Derive it with `#[derive(Context)]`, +/// which generates the `Patch` builder, the field constants, and the tracked reader. +pub trait Context: Clone + 'static { + /// A partial update: every field optional (TS `Partial`). + type Patch: Default; + /// Read access that records which fields a computed value reads. + type Reader<'a>: Copy + where + Self: 'a; + /// Field names in declaration order, camelCased for JS. + const FIELDS: &'static [&'static str]; + /// Shallow-merge `patch`; return the bit mask of the fields that actually changed. + fn apply(&mut self, patch: Self::Patch) -> u64; + fn reader<'a>(&'a self, deps: &'a Cell) -> Self::Reader<'a>; +} + +/// Per-field serialization, for bindings that mirror context fields into another runtime. +/// Opt in with `#[context(serialize)]`. +#[cfg(feature = "serde")] +pub trait SerializeFields: Context { + fn serialize_field( + &self, + index: usize, + serializer: S, + ) -> Result; +} + +/// Rebuild an event from its kind index and its payload fields — the fast path for +/// bindings, which know the kind already. Opt in with `#[event(deserialize)]`. +#[cfg(feature = "serde")] +pub trait DeserializeEvent: EventEnum { + fn deserialize_payload<'de, D: serde::Deserializer<'de>>( + kind: usize, + deserializer: D, + ) -> Result; +} + +/// The unit context, for machines that keep no data. +impl Context for () { + type Patch = (); + type Reader<'a> = (); + const FIELDS: &'static [&'static str] = &[]; + fn apply(&mut self, _patch: ()) -> u64 { + 0 + } + fn reader<'a>(&'a self, _deps: &'a Cell) {} +} + +#[cfg(feature = "serde")] +impl SerializeFields for () { + fn serialize_field( + &self, + _index: usize, + serializer: S, + ) -> Result { + serializer.serialize_unit() + } +} + +/// A typed handle to one context field (generated as `Context::FIELD_NAME` consts). +pub struct Field { + index: usize, + _context: PhantomData C>, +} + +impl Field { + #[doc(hidden)] + pub const fn new(index: usize) -> Self { + Self { + index, + _context: PhantomData, + } + } + pub const fn index(self) -> usize { + self.index + } + pub const fn bit(self) -> u64 { + 1 << self.index + } +} + +impl Clone for Field { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Field {} +impl Debug for Field { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Field({})", self.index) + } +} + +/// The type bundle of one machine — the Rust counterpart of the TS generics +/// ``. Implement it on a marker struct: +/// +/// ```ignore +/// pub struct Palette; +/// impl Types for Palette { +/// type State = PaletteState; +/// type Event = PaletteEvent; +/// type Context = PaletteCtx; +/// } +/// ``` +pub trait Types: 'static { + type State: StateEnum; + type Event: EventEnum; + type Context: Context; +} diff --git a/crates/core/tests/act.rs b/crates/core/tests/act.rs new file mode 100644 index 0000000..a8f26b5 --- /dev/null +++ b/crates/core/tests/act.rs @@ -0,0 +1,126 @@ +//! `act`: write-sugar for the most common action, a context patch computed from the +//! params. It slots in wherever an action does. +//! +//! Not ported: +//! - "infers Context from the slot — no per-call generics, even with computed": a +//! TypeScript inference check. A Rust patch is a typed builder (`Ctx::patch().field(v)`), +//! so a wrong value type is a compile error by construction. +//! +//! Port note: TS `act(a, b)` applies several patches; Rust's `act` takes one, so the +//! multi-patch form is consecutive `act`s. + +mod common; + +use common::{Log, build}; +use dunky_core::{Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Focus, + Set { value: i32 }, + Inc, + Bump, + Go, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + focused: bool, + value: i32, + n: i32, + label: String, + hit: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn writes_a_static_patch() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Focus, |t| t.act(|_| Ctx::patch().focused(true))) + })); + m.send(Ev::Focus); + assert!(m.context().focused); +} + +#[test] +fn writes_a_patch_derived_from_the_event() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Set, |t| { + t.act(|p| match p.event() { + Some(Ev::Set { value }) => Ctx::patch().value(*value), + _ => Ctx::patch(), + }) + }) + })); + m.send(Ev::Set { value: 42 }); + assert_eq!(m.context().value, 42); +} + +#[test] +fn reads_the_context() { + let m = build( + Config::::builder( + S::A, + Ctx { + n: 1, + ..Ctx::default() + }, + ) + .state(S::A, |s| { + s.on(EvKind::Inc, |t| { + t.act(|p| Ctx::patch().n(p.context().n + 1)) + }) + }), + ); + m.send(Ev::Inc); + m.send(Ev::Inc); + assert_eq!(m.context().n, 3); +} + +#[test] +fn a_later_patch_sees_earlier_writes() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Bump, |t| { + t.act(|_| Ctx::patch().n(5)) + .act(|p| Ctx::patch().label(format!("n={}", p.context().n))) + }) + })); + m.send(Ev::Bump); + assert_eq!(m.context().n, 5); + assert_eq!(m.context().label, "n=5"); +} + +#[test] +fn composes_with_a_target_and_other_actions_in_order() { + let log: Log = Log::default(); + let record = |phase: &'static str| { + let log = log.clone(); + move |p: &mut dunky_core::ActionParams<'_, M>| { + log.push(format!("{phase}: hit={}", p.context().hit)); + } + }; + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.target(S::B) + .run(record("before")) + .act(|_| Ctx::patch().hit(true)) + .run(record("after")) + }) + })); + m.send(Ev::Go); + assert_eq!(m.state(), S::B); + assert_eq!(log.entries(), ["before: hit=false", "after: hit=true"]); +} diff --git a/crates/core/tests/actions.rs b/crates/core/tests/actions.rs new file mode 100644 index 0000000..5106941 --- /dev/null +++ b/crates/core/tests/actions.rs @@ -0,0 +1,391 @@ +//! Actions: inline and named, run in order; `one_of` picks one branch by guard; entry and +//! exit run around the state switch; every machine owns its context. +//! +//! Not ported: +//! - "action params include `computed` (empty when none configured)": JS params carry a +//! `computed` object; Rust reads computed values by typed key (see computed.rs). +//! - "the context object identity is permanent across writes": JS object identity. A Rust +//! machine owns its context and every read borrows the live value, so no reference can +//! be held across a write. + +mod common; + +use common::{CounterEvent, Log, build, counter}; +use dunky_core::{ + Action, Branch, Config, Context, Event, Guard, GuardParams, Machine, State, Types, +}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, + C, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Go, + ToB, + Auto, + Mark, + Stay, + Set { value: String }, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + n: i32, + kind: char, + mobile: bool, + admin: bool, + last: String, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +fn when(f: impl Fn(&GuardParams<'_, M>) -> bool + 'static) -> Guard { + Guard::when(f) +} + +#[test] +fn runs_an_inline_action_that_updates_the_context() { + let m = build(&mut counter()); + m.send(CounterEvent::Inc); + m.send(CounterEvent::Inc); + assert_eq!(m.context().n, 2); +} + +#[test] +fn runs_multiple_actions_in_order() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.run(log.action("a")) + .run(log.action("b")) + .run(log.action("c")) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["a", "b", "c"]); +} + +#[test] +fn an_action_can_read_the_event_payload() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Set, |t| { + t.act(|p| match p.event() { + Some(Ev::Set { value }) => Ctx::patch().last(value.clone()), + _ => Ctx::patch(), + }) + }) + })); + m.send(Ev::Set { value: "hi".into() }); + assert_eq!(m.context().last, "hi"); +} + +#[test] +fn an_action_can_queue_an_event_via_send() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.on(EvKind::ToB, |t| t.target(S::B).run(|p| p.send(Ev::Mark))) + }) + .state(S::B, |s| { + s.on(EvKind::Mark, |t| t.run(log.action("marked"))) + }), + ); + m.send(Ev::ToB); + assert_eq!(m.state(), S::B); + assert_eq!(log.entries(), ["marked"]); +} + +#[test] +fn resolves_actions_by_name() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .action("inc", |p| { + let n = p.context().n; + p.set_context(Ctx::patch().n(n + 1)); + }) + .state(S::A, |s| { + s.on(EvKind::Go, |t| t.action("inc").action("inc")) + }), + ); + m.send(Ev::Go); + assert_eq!(m.context().n, 2); +} + +#[test] +fn named_and_inline_actions_run_in_order() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .action("first", log.action("first")) + .action("last", log.action("last")) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.action("first").run(log.action("inline")).action("last") + }) + }), + ); + m.send(Ev::Go); + assert_eq!(log.entries(), ["first", "inline", "last"]); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no action \"missing\"")] +fn panics_in_debug_when_an_action_name_is_not_registered() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.on(EvKind::Go, |t| t.action("missing"))), + ); + m.send(Ev::Go); +} + +#[test] +fn one_of_runs_the_first_branch_whose_guard_passes() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + kind: 'b', + ..Ctx::default() + }, + ) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([ + Branch::when( + when(|p| p.context().kind == 'a'), + [Action::run(log.action("a"))], + ), + Branch::when( + when(|p| p.context().kind == 'b'), + [Action::run(log.action("b"))], + ), + Branch::otherwise([Action::run(log.action("fallback"))]), + ])) + }) + }), + ); + m.send(Ev::Go); + assert_eq!(log.entries(), ["b"]); +} + +#[test] +fn one_of_falls_back_to_the_guardless_branch() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([ + Branch::when( + when(|p| p.context().n > 0), + [Action::run(log.action("positive"))], + ), + Branch::otherwise([Action::run(log.action("fallback"))]), + ])) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["fallback"]); +} + +#[test] +fn one_of_runs_nothing_when_no_branch_matches_and_there_is_no_fallback() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([Branch::when( + when(|p| p.context().n > 0), + [Action::run(log.action("x"))], + )])) + }) + })); + m.send(Ev::Go); + assert!(log.is_empty()); +} + +#[test] +fn one_of_composes_with_unconditional_actions_in_order() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + mobile: true, + ..Ctx::default() + }, + ) + .guard("isMobile", |p| p.context().mobile) + .action("lockScroll", log.action("lockScroll")) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.run(log.action("always-before")) + .push(Action::one_of([Branch::when( + "isMobile", + [Action::named("lockScroll")], + )])) + .run(log.action("always-after")) + }) + }), + ); + m.send(Ev::Go); + assert_eq!( + log.entries(), + ["always-before", "lockScroll", "always-after"] + ); +} + +#[test] +fn one_of_branch_guards_accept_registered_names() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + admin: true, + ..Ctx::default() + }, + ) + .guard("isAdmin", |p| p.context().admin) + .action("adminPath", log.action("admin")) + .action("userPath", log.action("user")) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([ + Branch::when("isAdmin", [Action::named("adminPath")]), + Branch::otherwise([Action::named("userPath")]), + ])) + }) + }), + ); + m.send(Ev::Go); + assert_eq!(log.entries(), ["admin"]); +} + +#[test] +fn exit_runs_before_the_transition_actions_and_entry_after() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.exit_run(log.action("exit:a")).on(EvKind::ToB, |t| { + t.target(S::B).run(log.action("action:toB")) + }) + }) + .state(S::B, |s| s.entry_run(log.action("entry:b"))), + ); + m.send(Ev::ToB); + assert_eq!(log.entries(), ["exit:a", "action:toB", "entry:b"]); + assert_eq!(m.state(), S::B); +} + +#[test] +fn the_initial_state_entry_does_not_run_at_construction() { + let log = Log::default(); + let _m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.entry_run(log.action("entry:a"))), + ); + assert!(log.is_empty()); +} + +#[test] +fn a_targetless_transition_skips_entry_and_exit() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.entry_run(log.action("entry:a")) + .exit_run(log.action("exit:a")) + .on(EvKind::Go, |t| t.act(|p| Ctx::patch().n(p.context().n + 1))) + })); + m.send(Ev::Go); + assert!(log.is_empty()); + assert_eq!(m.context().n, 1); +} + +#[test] +fn a_transition_targeting_the_current_state_also_skips_entry_and_exit() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.entry_run(log.action("entry:a")) + .exit_run(log.action("exit:a")) + .on(EvKind::Stay, |t| t.target(S::A).run(log.action("action"))) + })); + m.send(Ev::Stay); + assert_eq!(log.entries(), ["action"]); +} + +#[test] +fn entry_and_exit_run_named_actions_and_one_of() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + mobile: true, + ..Ctx::default() + }, + ) + .guard("isMobile", |p| p.context().mobile) + .action("logExit", log.action("exit:a")) + .action("mobileSetup", log.action("mobile")) + .action("desktopSetup", log.action("desktop")) + .state(S::A, |s| { + s.exit("logExit").on(EvKind::ToB, |t| t.target(S::B)) + }) + .state(S::B, |s| { + s.entry(Action::one_of([ + Branch::when("isMobile", [Action::named("mobileSetup")]), + Branch::otherwise([Action::named("desktopSetup")]), + ])) + }), + ); + m.send(Ev::ToB); + assert_eq!(log.entries(), ["exit:a", "mobile"]); +} + +#[test] +fn an_entry_action_can_queue_the_next_transition() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.on(EvKind::ToB, |t| t.target(S::B))) + .state(S::B, |s| { + s.entry_run(|p| p.send(Ev::Auto)) + .entry_run(log.action("entry:b")) + .on(EvKind::Auto, |t| t.target(S::C)) + }) + .state(S::C, |s| s.entry_run(log.action("entry:c"))), + ); + m.send(Ev::ToB); + assert_eq!(log.entries(), ["entry:b", "entry:c"]); + assert_eq!(m.state(), S::C); +} + +#[test] +fn a_machine_never_mutates_the_config_seed_context() { + let config = counter().build(); + let m = Machine::new(&config); + m.send(CounterEvent::Inc); + assert_eq!(m.context().n, 1); + assert_eq!(config.context().n, 0); +} + +#[test] +fn machines_built_from_one_config_are_isolated() { + let config = counter().build(); + let a = Machine::new(&config); + let b = Machine::new(&config); + a.send(CounterEvent::Inc); + assert_eq!(a.context().n, 1); + assert_eq!(b.context().n, 0); +} diff --git a/crates/core/tests/after.rs b/crates/core/tests/after.rs new file mode 100644 index 0000000..70264b5 --- /dev/null +++ b/crates/core/tests/after.rs @@ -0,0 +1,288 @@ +//! `after`: timed transitions while a state is active. Time is driven by the sans-IO test +//! `Clock` (the counterpart of vitest fake timers): it runs the machine's timer commands +//! and fires due timers as virtual time advances. +//! +//! Rust-only (a sans-IO host can fire a timer at any moment, which JS timers cannot): the +//! last three tests pin the SPEC's stale-timer and no-interleaving rules. + +mod common; + +use common::{Log, build}; +use dunky_core::testing::Clock; +use dunky_core::{Command, Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, + C, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Leave, + Back, + Chain, + Show, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + ms: u32, + n: i32, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn fires_a_fixed_delay_transition_after_the_delay() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.after(200, |t| t.target(S::B))), + ); + m.start(); + let mut clock = Clock::new(&m); + clock.advance(199); + assert_eq!(m.state(), S::A); + clock.advance(1); + assert_eq!(m.state(), S::B); +} + +#[test] +fn resolves_a_named_delay_that_reads_the_context() { + let m = build( + Config::::builder( + S::A, + Ctx { + ms: 500, + ..Ctx::default() + }, + ) + .delay("openDelay", |p| p.context().ms) + .state(S::A, |s| s.after("openDelay", |t| t.target(S::B))), + ); + m.start(); + let mut clock = Clock::new(&m); + clock.advance(499); + assert_eq!(m.state(), S::A); + clock.advance(1); + assert_eq!(m.state(), S::B); +} + +#[test] +fn leaving_the_state_first_cancels_its_timer() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.after(200, |t| t.target(S::B)) + .on(EvKind::Leave, |t| t.target(S::C)) + })); + m.start(); + let mut clock = Clock::new(&m); + m.send(Ev::Leave); + assert_eq!(m.state(), S::C); + clock.advance(500); + assert_eq!(m.state(), S::C); +} + +#[test] +fn timers_run_only_while_started_and_stop_cancels_a_pending_one() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.after(100, |t| t.target(S::B))), + ); + let mut clock = Clock::new(&m); + clock.advance(500); // never started: nothing scheduled + assert_eq!(m.state(), S::A); + + m.start(); + m.stop(); + clock.advance(500); + assert_eq!(m.state(), S::A); +} + +#[test] +fn an_after_handler_falls_through_to_the_first_passing_candidate() { + let m = build( + Config::::builder( + S::A, + Ctx { + n: 8, + ..Ctx::default() + }, + ) + .state(S::A, |s| { + s.after(100, |t| t.guard_fn(|p| p.context().n >= 10).target(S::B)) + .after_or(|t| t.target(S::C)) + }), + ); + m.start(); + Clock::new(&m).advance(100); + assert_eq!(m.state(), S::C); // n=8 < 10: the fallback +} + +#[test] +fn a_timed_transition_runs_its_actions_on_the_way() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(50, |t| t.target(S::B).run(log.action("tick"))) + }) + .state(S::B, |s| s.entry_run(log.action("entered-b"))), + ); + m.start(); + Clock::new(&m).advance(50); + assert_eq!(log.entries(), ["tick", "entered-b"]); + assert_eq!(m.state(), S::B); +} + +#[test] +fn a_state_entered_later_schedules_its_timer_on_entry() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.on(EvKind::Show, |t| t.target(S::B))) + .state(S::B, |s| s.after(300, |t| t.target(S::A))), + ); + m.start(); + let mut clock = Clock::new(&m); + m.send(Ev::Show); + assert_eq!(m.state(), S::B); + clock.advance(300); + assert_eq!(m.state(), S::A); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no delay \"missing\"")] +fn panics_in_debug_when_a_delay_name_is_not_registered() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.after("missing", |t| t.target(S::B))), + ); + m.start(); +} + +#[test] +fn events_sent_by_a_timed_transition_drain_in_the_same_run() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(10, |t| t.target(S::B).run(|p| p.send(Ev::Chain))) + }) + .state(S::B, |s| { + s.on(EvKind::Chain, |t| t.target(S::C).run(log.action("chained"))) + }) + .state(S::C, |s| s.entry_run(log.action("entered-c"))), + ); + m.start(); + Clock::new(&m).advance(10); + assert_eq!(m.state(), S::C); + assert_eq!(log.entries(), ["chained", "entered-c"]); +} + +#[test] +fn re_entering_a_timed_state_restarts_its_timer() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(100, |t| t.target(S::C)) + .on(EvKind::Leave, |t| t.target(S::B)) + }) + .state(S::B, |s| s.on(EvKind::Back, |t| t.target(S::A))), + ); + m.start(); + let mut clock = Clock::new(&m); + clock.advance(60); + m.send(Ev::Leave); + assert_eq!(m.state(), S::B); + clock.advance(50); // past the first entry's deadline, but that timer is gone + assert_eq!(m.state(), S::B); + m.send(Ev::Back); // a fresh 100ms timer + clock.advance(60); + assert_eq!(m.state(), S::A); + clock.advance(40); + assert_eq!(m.state(), S::C); +} + +#[test] +fn a_cancelled_timer_fired_anyway_is_ignored() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(100, |t| t.target(S::C)) + .on(EvKind::Leave, |t| t.target(S::B)) + }) + .state(S::B, |s| s.on(EvKind::Back, |t| t.target(S::A))), + ); + m.start(); + let [Command::StartTimer { id, .. }] = m.take_commands()[..] else { + panic!("expected one StartTimer") + }; + m.send(Ev::Leave); // emits the cancel; this host fires the timer anyway + m.fire_timer(id); + assert_eq!(m.state(), S::B); + m.send(Ev::Back); // a re-entry schedules a new timer; the old id stays dead + m.fire_timer(id); + assert_eq!(m.state(), S::A); +} + +#[test] +fn a_timer_fired_mid_transition_waits_for_it_to_complete() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.after(10, |t| t.target(S::C).run(log.action("timer"))) + .on(EvKind::Chain, |t| { + t.run(log.action("chain:start")) + .act(|p| Ctx::patch().n(p.context().n + 1)) + .run(log.action("chain:end")) + }) + })); + m.start(); + let [Command::StartTimer { id, .. }] = m.take_commands()[..] else { + panic!("expected one StartTimer") + }; + let _ = m.subscribe({ + let m = m.clone(); + move || m.fire_timer(id) // the host fires while `chain` is in flight + }); + m.send(Ev::Chain); + assert_eq!(log.entries(), ["chain:start", "chain:end", "timer"]); + assert_eq!(m.state(), S::C); +} + +#[test] +fn a_timer_queued_behind_an_exit_and_re_entry_of_its_state_is_stale() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(100, |t| t.target(S::C)) + .on(EvKind::Leave, |t| t.target(S::B)) + .on(EvKind::Chain, |t| { + t.run(|p| { + p.send(Ev::Leave); + p.send(Ev::Back); + }) + .act(|p| Ctx::patch().n(p.context().n + 1)) + }) + }) + .state(S::B, |s| s.on(EvKind::Back, |t| t.target(S::A))), + ); + m.start(); + let [Command::StartTimer { id, .. }] = m.take_commands()[..] else { + panic!("expected one StartTimer") + }; + // Fired at the `chain` patch, the timer queues behind `leave` and `back`. By the time + // it runs, `a` was exited and re-entered: the timer belongs to the previous entry. + let _ = m.subscribe({ + let m = m.clone(); + move || m.fire_timer(id) + }); + m.send(Ev::Chain); + assert_eq!(m.state(), S::A); +} diff --git a/crates/core/tests/bare_fn_entry.rs b/crates/core/tests/bare_fn_entry.rs new file mode 100644 index 0000000..8ad70c8 --- /dev/null +++ b/crates/core/tests/bare_fn_entry.rs @@ -0,0 +1,76 @@ +//! An action-only candidate (no target, no guard), the Rust form of the TS bare-fn entry, +//! is a guardless fallback in a fallthrough list. +//! +//! Not ported. Rust has no bare-fn shorthand: an action-only candidate is built like any +//! other transition, so these collapse into tests elsewhere: +//! - "a standalone bare fn runs as an action, no state change": transitions.rs +//! (a_targetless_transition_runs_its_actions_and_stays_in_the_state). +//! - "act(...) works bare in the entry slot": act.rs (writes_a_static_patch). +//! - "the bare fn sees the (narrowed) event": actions.rs (an_action_can_read_the_event_payload). +//! - "object form still works for targets alongside a bare-fn sibling event": there is one +//! form only; targets and action-only handlers are covered by transitions.rs. + +mod common; + +use common::{Log, build}; +use dunky_core::{Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Go, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + hit: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn an_action_only_candidate_after_failing_guards_is_the_fallback() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| t.guard_fn(|_| false).target(S::B)) + .on(EvKind::Go, |t| { + t.run(log.action("fallback")) + .act(|_| Ctx::patch().hit(true)) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["fallback"]); + assert!(m.context().hit); + assert_eq!(m.state(), S::A); // the fallback has no target +} + +#[test] +fn a_passing_guarded_candidate_before_an_action_only_one_wins() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.guard_fn(|_| true) + .target(S::B) + .run(log.action("transition")) + }) + .on(EvKind::Go, |t| { + t.run(log.action("fallback")) + .act(|_| Ctx::patch().hit(true)) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["transition"]); + assert!(!m.context().hit); + assert_eq!(m.state(), S::B); +} diff --git a/crates/core/tests/broadcast.rs b/crates/core/tests/broadcast.rs new file mode 100644 index 0000000..69c82cb --- /dev/null +++ b/crates/core/tests/broadcast.rs @@ -0,0 +1,111 @@ +//! The notify bus behind `Machine::subscribe` (and the connector's wake): who fires in +//! the pass where membership changes. The bus is private, so it is driven through the +//! machine. +//! +//! Not ported: +//! - "the remover detaches; removing twice is harmless": `Subscription::unsubscribe` +//! consumes the handle, so a second removal does not compile; detaching is pinned in +//! subscribe.rs. +//! +//! Port note: "clear() drops everyone at once" goes through `Connector::destroy`, the only +//! public path that clears a bus. + +mod common; + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use common::{Count, CounterEvent, Log, build, counter}; +use dunky_core::{Connector, Subscription}; + +/// A slot for a subscription made after the listener that will remove it. +type Slot = Rc>>; + +fn unsubscribe(slot: &Slot) { + if let Some(sub) = slot.borrow_mut().take() { + sub.unsubscribe(); + } +} + +#[test] +fn a_change_wakes_every_listener_in_subscription_order() { + let m = build(&mut counter()); + let log = Log::default(); + let _ = m.subscribe(log.listener("a")); + let _ = m.subscribe(log.listener("b")); + assert!(log.is_empty()); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "b"]); +} + +#[test] +fn a_listener_removed_mid_pass_does_not_fire_in_that_pass() { + let m = build(&mut counter()); + let log = Log::default(); + let off_b = Slot::default(); + let _ = m.subscribe({ + let (log, off_b) = (log.clone(), off_b.clone()); + move || { + log.push("a"); + unsubscribe(&off_b); + } + }); + *off_b.borrow_mut() = Some(m.subscribe(log.listener("b"))); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a"]); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "a"]); +} + +#[test] +fn a_listener_added_mid_pass_waits_for_the_next_notify() { + let m = build(&mut counter()); + let log = Log::default(); + let added = Cell::new(false); + let _ = m.subscribe({ + let (m, log) = (m.clone(), log.clone()); + move || { + log.push("a"); + if !added.replace(true) { + let _ = m.subscribe(log.listener("late")); + } + } + }); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a"]); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "a", "late"]); +} + +#[test] +fn a_nested_notify_does_not_resurrect_a_listener_removed_in_the_outer_pass() { + let m = build(&mut counter()); + let log = Log::default(); + let off_b = Slot::default(); + let nested = Cell::new(false); + let _ = m.subscribe({ + let (m, log, off_b) = (m.clone(), log.clone(), off_b.clone()); + move || { + log.push("a"); + if !nested.replace(true) { + unsubscribe(&off_b); + m.set_context(Count::patch().n(99)); // notifies while the outer pass runs + } + } + }); + *off_b.borrow_mut() = Some(m.subscribe(log.listener("b"))); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "a"]); // b fired in neither pass +} + +#[test] +fn clearing_drops_every_listener_at_once() { + let m = build(&mut counter()); + let c = Connector::new(&m, |snap| snap.view.context().n, 0, Vec::new()); + let log = Log::default(); + let _ = c.subscribe(log.listener("a")); + let _ = c.subscribe(log.listener("b")); + c.destroy(); + c.set_props(1); // a props change wakes the connector's own bus + assert!(log.is_empty()); +} diff --git a/crates/core/tests/common/mod.rs b/crates/core/tests/common/mod.rs new file mode 100644 index 0000000..545f013 --- /dev/null +++ b/crates/core/tests/common/mod.rs @@ -0,0 +1,181 @@ +//! Fixtures shared by several test files: recorders (the stand-ins for pushing into an +//! array and for `vi.fn()`) and the two small machines most behaviors are pinned on. +#![allow(dead_code)] + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use dunky_core::{ + ActionParams, Cleanup, Config, ConfigBuilder, Context, Event, Machine, State, Types, +}; + +/// Records entries in order. +pub struct Log(Rc>>); + +impl Clone for Log { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Default for Log { + fn default() -> Self { + Self(Rc::default()) + } +} + +impl Log { + pub fn push(&self, entry: impl Into) { + self.0.borrow_mut().push(entry.into()); + } + pub fn entries(&self) -> Vec { + self.0.borrow().clone() + } + pub fn is_empty(&self) -> bool { + self.0.borrow().is_empty() + } + pub fn clear(&self) { + self.0.borrow_mut().clear(); + } +} + +impl Log { + /// An action that records `entry`. + pub fn action( + &self, + entry: &'static str, + ) -> impl Fn(&mut ActionParams<'_, T>) + 'static { + let log = self.clone(); + move |_| log.push(entry) + } + /// An effect cleanup that records `entry`. + fn cleanup(&self, entry: &'static str) -> Cleanup { + let log = self.clone(); + Box::new(move || log.push(entry)) + } + /// A plain listener that records `entry`. + pub fn listener(&self, entry: &'static str) -> impl Fn() + 'static { + let log = self.clone(); + move || log.push(entry) + } + /// An effect that records `start` when it starts and returns a cleanup recording + /// `cleanup`; either may be omitted. + pub fn effect( + &self, + start: Option<&'static str>, + cleanup: Option<&'static str>, + ) -> impl Fn(&mut ActionParams<'_, T>) -> Option + 'static { + let log = self.clone(); + move |_| { + if let Some(entry) = start { + log.push(entry); + } + cleanup.map(|entry| log.cleanup(entry)) + } + } +} + +/// Counts calls. +#[derive(Clone, Default)] +pub struct Calls(Rc>); + +impl Calls { + pub fn hit(&self) { + self.0.set(self.0.get() + 1); + } + pub fn count(&self) -> usize { + self.0.get() + } + pub fn listener(&self) -> impl Fn() + 'static { + let calls = self.clone(); + move || calls.hit() + } + pub fn action(&self) -> impl Fn(&mut ActionParams<'_, T>) + 'static { + let calls = self.clone(); + move |_| calls.hit() + } + /// An effect that counts its starts; its cleanup, if `cleanups` is given, counts there. + pub fn effect( + &self, + cleanups: Option<&Calls>, + ) -> impl Fn(&mut ActionParams<'_, T>) -> Option + 'static { + let calls = self.clone(); + let cleanups = cleanups.cloned(); + move |_| { + calls.hit(); + cleanups + .clone() + .map(|c| Box::new(move || c.hit()) as Cleanup) + } + } +} + +pub fn build(builder: &mut ConfigBuilder) -> Machine { + Machine::new(&builder.build()) +} + +/// Two states, no context: `a --toB--> b --toA--> a`. +pub struct Ab; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +pub enum AbState { + A, + B, +} + +#[derive(Clone, Debug, Event)] +pub enum AbEvent { + ToB, + ToA, +} + +impl Types for Ab { + type State = AbState; + type Event = AbEvent; + type Context = (); +} + +pub fn ab() -> ConfigBuilder { + let mut b = Config::::builder(AbState::A, ()); + b.state(AbState::A, |s| { + s.on(AbEventKind::ToB, |t| t.target(AbState::B)) + }) + .state(AbState::B, |s| { + s.on(AbEventKind::ToA, |t| t.target(AbState::A)) + }); + b +} + +/// One state counting `inc` events in `n`. +pub struct Counter; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +pub enum CounterState { + Idle, +} + +#[derive(Clone, Debug, Event)] +pub enum CounterEvent { + Inc, +} + +#[derive(Clone, Debug, PartialEq, Context)] +pub struct Count { + pub n: i32, +} + +impl Types for Counter { + type State = CounterState; + type Event = CounterEvent; + type Context = Count; +} + +pub fn counter() -> ConfigBuilder { + let mut b = Config::::builder(CounterState::Idle, Count { n: 0 }); + b.state(CounterState::Idle, |s| { + s.on(CounterEventKind::Inc, |t| { + t.act(|p| Count::patch().n(p.context().n + 1)) + }) + }); + b +} diff --git a/crates/core/tests/compose.rs b/crates/core/tests/compose.rs new file mode 100644 index 0000000..78ea8da --- /dev/null +++ b/crates/core/tests/compose.rs @@ -0,0 +1,292 @@ +//! Composition: several machines run as one unit with a shared start / stop, plus `sync` +//! (a cross-member reaction) and `combine` (a value-deduped derivation across members). +//! +//! Not ported: +//! - the "members are reachable by name" half of "start() starts every member": the JS +//! `members` map. A Rust composition keeps no names; callers keep their own handles. +//! - "re-entrant send from sync is queued, not run mid-drain": the damped-forward test runs +//! the same rule for 200 sends and asserts the same end state. + +mod common; + +use std::rc::Rc; + +use common::{AbState, Calls, CounterEvent, Log, ab, build, counter}; +use dunky_core::{Composition, Config, Event, Machine, Member, State, Subscription, Types}; + +struct Popup; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum PopupState { + Closed, + Open, +} + +#[derive(Clone, Debug, Event)] +enum PopupEvent { + Focus, + Escape, +} + +impl Types for Popup { + type State = PopupState; + type Event = PopupEvent; + type Context = (); +} + +struct Submenu; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum SubmenuState { + Hidden, + Shown, +} + +#[derive(Clone, Debug, Event)] +enum SubmenuEvent { + Open, + Close, +} + +impl Types for Submenu { + type State = SubmenuState; + type Event = SubmenuEvent; + type Context = (); +} + +/// A started composition of a popup (closed <-> open) and a submenu (hidden <-> shown). +fn menu() -> (Composition, Machine, Machine) { + let popup = build( + Config::::builder(PopupState::Closed, ()) + .state(PopupState::Closed, |s| { + s.on(PopupEventKind::Focus, |t| t.target(PopupState::Open)) + }) + .state(PopupState::Open, |s| { + s.on(PopupEventKind::Escape, |t| t.target(PopupState::Closed)) + }), + ); + let submenu = build( + Config::::builder(SubmenuState::Hidden, ()) + .state(SubmenuState::Hidden, |s| { + s.on(SubmenuEventKind::Open, |t| t.target(SubmenuState::Shown)) + }) + .state(SubmenuState::Shown, |s| { + s.on(SubmenuEventKind::Close, |t| t.target(SubmenuState::Hidden)) + }), + ); + let group = Composition::new().with(&popup).with(&submenu); + group.start(); + (group, popup, submenu) +} + +#[test] +fn start_starts_every_member_in_order() { + let log = Log::default(); + let a = build(ab().state(AbState::A, |s| s.effect_run(log.effect(Some("a"), None)))); + let b = build(ab().state(AbState::A, |s| s.effect_run(log.effect(Some("b"), None)))); + let group = Composition::new().with(&a).with(&b); + assert!(log.is_empty()); + group.start(); + assert_eq!(log.entries(), ["a", "b"]); +} + +#[test] +fn stop_stops_members_in_reverse_order() { + let log = Log::default(); + let a = build(ab().state(AbState::A, |s| { + s.effect_run(log.effect(None, Some("a:cleanup"))) + })); + let b = build(ab().state(AbState::A, |s| { + s.effect_run(log.effect(None, Some("b:cleanup"))) + })); + let group = Composition::new().with(&a).with(&b); + group.start(); + group.stop(); + assert_eq!(log.entries(), ["b:cleanup", "a:cleanup"]); +} + +#[test] +fn members_stay_independent() { + let (_group, popup, submenu) = menu(); + popup.send(PopupEvent::Focus); + submenu.send(SubmenuEvent::Open); + assert_eq!(popup.state(), PopupState::Open); + assert_eq!(submenu.state(), SubmenuState::Shown); +} + +#[test] +fn sync_reacts_to_any_member_change_but_not_on_setup() { + let (group, popup, submenu) = menu(); + popup.send(PopupEvent::Focus); + submenu.send(SubmenuEvent::Open); + // Rule: when the popup closes, close the submenu too. + let _ = group.sync({ + let (popup, submenu) = (popup.clone(), submenu.clone()); + move || { + if popup.matches(PopupState::Closed) { + submenu.send(SubmenuEvent::Close); + } + } + }); + assert_eq!(submenu.state(), SubmenuState::Shown); + popup.send(PopupEvent::Escape); + assert_eq!(submenu.state(), SubmenuState::Hidden); +} + +#[test] +fn a_sync_rule_is_disposed_on_stop() { + let (group, popup, _submenu) = menu(); + let calls = Calls::default(); + let _ = group.sync(calls.listener()); + popup.send(PopupEvent::Focus); + assert_eq!(calls.count(), 1); + group.stop(); + popup.start(); // the member still runs, the rule is gone + popup.send(PopupEvent::Escape); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_sync_rule_detaches_early_when_unsubscribed() { + let (group, popup, _submenu) = menu(); + let calls = Calls::default(); + let sub = group.sync(calls.listener()); + popup.send(PopupEvent::Focus); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + popup.send(PopupEvent::Escape); + assert_eq!(calls.count(), 1); +} + +#[test] +fn combine_derives_one_value_across_members() { + let (group, popup, submenu) = menu(); + let view = group.combine({ + let (popup, submenu) = (popup.clone(), submenu.clone()); + move || (popup.matches(PopupState::Open), submenu.state()) + }); + assert_eq!(view.value(), (false, SubmenuState::Hidden)); + popup.send(PopupEvent::Focus); + submenu.send(SubmenuEvent::Open); + assert_eq!(view.value(), (true, SubmenuState::Shown)); +} + +#[test] +fn a_combined_subscription_fires_only_when_the_value_changes() { + let (group, popup, submenu) = menu(); + let seen: Log = Log::default(); + let _ = group + .combine({ + let popup = popup.clone(); + move || popup.matches(PopupState::Open) + }) + .subscribe({ + let seen = seen.clone(); + move |open| seen.push(*open) + }); + submenu.send(SubmenuEvent::Open); // a member changed, the value did not: silent + assert!(seen.is_empty()); + popup.send(PopupEvent::Focus); + assert_eq!(seen.entries(), [true]); +} + +#[test] +fn a_combined_subscription_is_disposed_on_stop() { + let (group, popup, _submenu) = menu(); + let calls = Calls::default(); + let _ = group + .combine({ + let popup = popup.clone(); + move || popup.state() + }) + .subscribe({ + let calls = calls.clone(); + move |_| calls.hit() + }); + popup.send(PopupEvent::Focus); + assert_eq!(calls.count(), 1); + group.stop(); + popup.start(); + popup.send(PopupEvent::Escape); + assert_eq!(calls.count(), 1); +} + +/// A member that counts unsubscribes, to see whether a detach runs twice. +#[derive(Clone)] +struct CountingMember(Calls); + +impl Member for CountingMember { + fn start(&self) {} + fn stop(&self) {} + fn subscribe_any(&self, _listener: Rc) -> Subscription { + let unsubscribes = self.0.clone(); + Subscription::new(move || unsubscribes.hit()) + } +} + +#[test] +fn stop_does_not_re_run_a_detach_already_run_by_hand() { + let unsubscribes = Calls::default(); + let member = CountingMember(unsubscribes.clone()); + let group = Composition::new().with(&member).with(&member); + group.start(); + let sync = group.sync(|| {}); + let combined = group.combine(|| 0).subscribe(|_| {}); + sync.unsubscribe(); + combined.unsubscribe(); + assert_eq!(unsubscribes.count(), 4); // one per member per subscription + group.stop(); + assert_eq!(unsubscribes.count(), 4); +} + +#[test] +fn a_damped_cross_member_forward_converges_with_linear_sync_calls() { + // `sync` wakes on every member, including the one it sends to, so the rule must damp + // its own forward (send only while b is behind a). + let a = build(&mut counter()); + let b = build(&mut counter()); + let group = Composition::new().with(&a).with(&b); + group.start(); + let calls = Calls::default(); + let _ = group.sync({ + let (a, b, calls) = (a.clone(), b.clone(), calls.clone()); + move || { + calls.hit(); + if a.context().n > b.context().n { + b.send(CounterEvent::Inc); + } + } + }); + const OPS: usize = 200; + for _ in 0..OPS { + a.send(CounterEvent::Inc); + } + assert_eq!(a.context().n, OPS as i32); + assert_eq!(b.context().n, OPS as i32); + assert_eq!(calls.count(), OPS * 2); // one wake from a, one from b, per send +} + +#[test] +fn a_combined_listener_may_send_to_a_member() { + let (group, popup, submenu) = menu(); + let seen: Log<(bool, SubmenuState)> = Log::default(); + let _ = group + .combine({ + let (popup, submenu) = (popup.clone(), submenu.clone()); + move || (popup.matches(PopupState::Open), submenu.state()) + }) + .subscribe({ + let (submenu, seen) = (submenu.clone(), seen.clone()); + move |value| { + seen.push(*value); + if *value == (true, SubmenuState::Hidden) { + submenu.send(SubmenuEvent::Open); // the member notifies at once + } + } + }); + popup.send(PopupEvent::Focus); + assert_eq!( + seen.entries(), + [(true, SubmenuState::Hidden), (true, SubmenuState::Shown)] + ); +} diff --git a/crates/core/tests/computed.rs b/crates/core/tests/computed.rs new file mode 100644 index 0000000..4e8bfd1 --- /dev/null +++ b/crates/core/tests/computed.rs @@ -0,0 +1,366 @@ +//! Computed values: lazy, memoized derivations of context, state and other computed +//! values, recomputed only when an input they read has changed. +//! +//! Not ported: +//! - "no computed config → params.computed is an empty bag": JS params carry a `computed` +//! object; Rust reads computed values by typed key, so there is no such object. +//! - "derives a value from context, readable in an action", "a computed can derive from +//! another computed" and "reads the current state": each is the opening step of other +//! tests here (recomputes_after_the_context_it_reads_changes, +//! an_upstream_change_propagates_through_a_chain, and the two state-reader tests). +//! - "a computed selection fires on change and dedups (value-gated)": the same +//! `select_computed` contract as subscribe.rs (select_computed_selects_a_derived_value). + +mod common; + +use common::{Calls, Log, build}; +use dunky_core::{ + ComputedKey, Config, ConfigBuilder, Context, Event, Machine, State, StateEnum, Types, +}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + Idle, + Busy, + Done, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Read, + Add, + BumpN, + BumpOther, + Rename, + UseDiscount, + SetDiscount(i32), + SetNote, + Go, + Finish, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + items: Vec, + n: i32, + other: i32, + first: String, + last: String, + use_discount: bool, + price: i32, + discount: i32, + note: String, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +/// Any-state handlers write the context; `go` / `finish` walk idle -> busy -> done. +/// Tests add their computed values and a `read` handler. +fn builder(ctx: Ctx) -> ConfigBuilder { + let mut b = Config::::builder(S::Idle, ctx); + b.on(EvKind::Add, |t| { + t.act(|p| { + let mut items = p.context().items.clone(); + items.push(1); + Ctx::patch().items(items) + }) + }) + .on(EvKind::BumpN, |t| { + t.act(|p| Ctx::patch().n(p.context().n + 1)) + }) + .on(EvKind::BumpOther, |t| { + t.act(|p| Ctx::patch().other(p.context().other + 1)) + }) + .on(EvKind::Rename, |t| t.act(|_| Ctx::patch().first("Grace"))) + .on(EvKind::UseDiscount, |t| { + t.act(|_| Ctx::patch().use_discount(true)) + }) + .on(EvKind::SetDiscount, |t| { + t.act(|p| match p.event() { + Some(Ev::SetDiscount(discount)) => Ctx::patch().discount(*discount), + _ => Ctx::patch(), + }) + }) + .on(EvKind::SetNote, |t| t.act(|_| Ctx::patch().note("hi"))) + .state(S::Idle, |s| s.on(EvKind::Go, |t| t.target(S::Busy))) + .state(S::Busy, |s| s.on(EvKind::Finish, |t| t.target(S::Done))); + b +} + +fn names() -> Ctx { + Ctx { + first: "Ada".into(), + last: "Lovelace".into(), + ..Ctx::default() + } +} + +#[test] +fn recomputes_after_the_context_it_reads_changes() { + let seen: Log = Log::default(); + let mut b = builder(Ctx::default()); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.on(EvKind::Read, |t| { + let seen = seen.clone(); + t.run(move |p| seen.push(*p.computed(is_empty))) + }); + let m = build(&mut b); + m.send(Ev::Read); + m.send(Ev::Add); + m.send(Ev::Read); + assert_eq!(seen.entries(), [true, false]); +} + +#[test] +fn is_readable_in_a_guard() { + let mut b = builder(Ctx::default()); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.state(S::Idle, |s| { + s.on(EvKind::Finish, |t| { + t.guard_fn(move |p| !*p.computed(is_empty)).target(S::Done) + }) + }); + let m = build(&mut b); + m.send(Ev::Finish); // empty: blocked + assert_eq!(m.state(), S::Idle); + m.send(Ev::Add); + m.send(Ev::Finish); + assert_eq!(m.state(), S::Done); +} + +#[test] +fn is_readable_in_an_effect_at_start() { + let seen: Log = Log::default(); + let mut b = builder(Ctx { + items: vec![1, 2, 3], + ..Ctx::default() + }); + let count = b.computed("count", |p| p.context.items().len()); + b.state(S::Idle, |s| { + let seen = seen.clone(); + s.effect_run(move |p| { + seen.push(*p.computed(count)); + None + }) + }); + let m = build(&mut b); + m.start(); + assert_eq!(seen.entries(), [3]); +} + +#[test] +fn memoizes_across_writes_to_fields_it_does_not_read() { + let runs = Calls::default(); + let mut b = builder(Ctx { + n: 2, + ..Ctx::default() + }); + let double = b.computed("double", { + let runs = runs.clone(); + move |p| { + runs.hit(); + p.context.n() * 2 + } + }); + b.on(EvKind::Read, |t| { + t.run(move |p| { + p.computed(double); + }) + }); + let m = build(&mut b); + m.send(Ev::Read); + assert_eq!(runs.count(), 1); + m.send(Ev::Read); + assert_eq!(runs.count(), 1); + m.send(Ev::BumpOther); // `double` does not read `other` + m.send(Ev::Read); + assert_eq!(runs.count(), 1); +} + +#[test] +fn an_upstream_change_propagates_through_a_chain() { + let seen: Log = Log::default(); + let mut b = builder(names()); + let full_name = b.computed("fullName", |p| { + format!("{} {}", p.context.first(), p.context.last()) + }); + let greeting = b.computed("greeting", move |p| { + format!("Hi, {}", p.computed(full_name)) + }); + b.on(EvKind::Read, |t| { + let seen = seen.clone(); + t.run(move |p| seen.push((*p.computed(greeting)).clone())) + }); + let m = build(&mut b); + m.send(Ev::Read); + m.send(Ev::Rename); + m.send(Ev::Read); + assert_eq!(seen.entries(), ["Hi, Ada Lovelace", "Hi, Grace Lovelace"]); +} + +#[test] +fn a_computed_may_read_one_defined_after_it() { + // Keys are numbered in definition order, so a forward reference is a const key. + const FULL_NAME: ComputedKey = ComputedKey::new(1); + let mut b = builder(names()); + let greeting = b.computed("greeting", |p| format!("Hi, {}", p.computed(FULL_NAME))); + let full_name = b.computed("fullName", |p| { + format!("{} {}", p.context.first(), p.context.last()) + }); + assert_eq!(full_name.id(), FULL_NAME.id()); + let m = build(&mut b); + assert_eq!(*m.computed(greeting), "Hi, Ada Lovelace"); +} + +#[test] +fn a_chain_recomputes_only_when_its_inputs_change() { + let base_runs = Calls::default(); + let derived_runs = Calls::default(); + let mut b = builder(Ctx { + n: 1, + ..Ctx::default() + }); + let base = b.computed("base", { + let runs = base_runs.clone(); + move |p| { + runs.hit(); + p.context.n() * 10 + } + }); + let derived = b.computed("derived", { + let runs = derived_runs.clone(); + move |p| { + runs.hit(); + *p.computed(base) + 1 + } + }); + b.on(EvKind::Read, |t| { + t.run(move |p| { + p.computed(derived); + }) + }); + let m = build(&mut b); + let runs = || (base_runs.count(), derived_runs.count()); + m.send(Ev::Read); + assert_eq!(runs(), (1, 1)); + m.send(Ev::BumpOther); // neither reads `other` + m.send(Ev::Read); + assert_eq!(runs(), (1, 1)); + m.send(Ev::BumpN); // base reads n: the whole chain invalidates + m.send(Ev::Read); + assert_eq!(runs(), (2, 2)); +} + +#[test] +fn the_machine_exposes_the_current_value() { + let mut b = builder(Ctx::default()); + let count = b.computed("count", |p| p.context.items().len()); + let m = build(&mut b); + assert_eq!(*m.computed(count), 0); + m.send(Ev::Add); + assert_eq!(*m.computed(count), 1); +} + +#[test] +fn the_machine_exposes_chained_values() { + let mut b = builder(names()); + let full = b.computed("full", |p| { + format!("{} {}", p.context.first(), p.context.last()) + }); + let greet = b.computed("greet", move |p| format!("Hi, {}", p.computed(full))); + let m = build(&mut b); + assert_eq!(*m.computed(full), "Ada Lovelace"); + assert_eq!(*m.computed(greet), "Hi, Ada Lovelace"); +} + +/// `total` reads `discount` only while `use_discount` is on. +fn discounted() -> (Machine, ComputedKey, Calls) { + let runs = Calls::default(); + let mut b = builder(Ctx { + price: 100, + discount: 10, + ..Ctx::default() + }); + let total = b.computed("total", { + let runs = runs.clone(); + move |p| { + runs.hit(); + if *p.context.use_discount() { + p.context.price() - p.context.discount() + } else { + *p.context.price() + } + } + }); + (build(&mut b), total, runs) +} + +#[test] +fn a_field_read_only_on_an_untaken_branch_is_not_a_dependency() { + let (m, total, runs) = discounted(); + assert_eq!(*m.computed(total), 100); + assert_eq!(runs.count(), 1); + m.send(Ev::SetDiscount(50)); + assert_eq!(*m.computed(total), 100); + assert_eq!(runs.count(), 1); +} + +#[test] +fn flipping_the_branch_re_tracks_the_dependencies() { + let (m, total, runs) = discounted(); + assert_eq!(*m.computed(total), 100); + m.send(Ev::UseDiscount); + assert_eq!(*m.computed(total), 90); + assert_eq!(runs.count(), 2); + m.send(Ev::SetDiscount(30)); // read on the new branch: now a dependency + assert_eq!(*m.computed(total), 70); + assert_eq!(runs.count(), 3); +} + +/// `is_busy` and `label` read only the state. +fn state_reader() -> (Machine, ComputedKey, ComputedKey, Calls) { + let runs = Calls::default(); + let mut b = builder(Ctx::default()); + let is_busy = b.computed("isBusy", |p| p.state() == S::Busy); + let label = b.computed("label", { + let runs = runs.clone(); + move |p| { + runs.hit(); + format!("state={}", p.state().name()) + } + }); + let m = build(&mut b); + m.start(); + (m, is_busy, label, runs) +} + +#[test] +fn a_transition_invalidates_a_state_reading_computed() { + let (m, is_busy, label, _) = state_reader(); + assert!(!*m.computed(is_busy)); + m.send(Ev::Go); + assert!(*m.computed(is_busy)); + assert_eq!(*m.computed(label), "state=busy"); + m.send(Ev::Finish); + assert!(!*m.computed(is_busy)); + assert_eq!(*m.computed(label), "state=done"); +} + +#[test] +fn a_context_write_does_not_recompute_a_state_only_computed() { + let (m, _, label, runs) = state_reader(); + assert_eq!(*m.computed(label), "state=idle"); + assert_eq!(runs.count(), 1); + m.send(Ev::SetNote); + assert_eq!(*m.computed(label), "state=idle"); + assert_eq!(runs.count(), 1); + m.send(Ev::Go); + assert_eq!(*m.computed(label), "state=busy"); + assert_eq!(runs.count(), 2); +} diff --git a/crates/core/tests/connector.rs b/crates/core/tests/connector.rs new file mode 100644 index 0000000..8529110 --- /dev/null +++ b/crates/core/tests/connector.rs @@ -0,0 +1,262 @@ +//! Connector: a memoized view surface over a machine and props, its coarse subscription, +//! and reactions (machine change -> consumer callback) wired on start, torn down on stop. +//! +//! Not ported: +//! - "forwards select for per-field (canvas/Lit) consumption": Rust's `Connector` does not +//! forward `select`; per-field selections come from the machine (`Machine::select`, +//! pinned in subscribe.rs). + +mod common; + +use std::cell::RefCell; +use std::rc::Rc; + +use common::{Ab, AbEvent, AbState, Calls, Log, ab, build}; +use dunky_core::{ + Config, Connector, Context, Event, Machine, Reaction, State, Subscription, Types, +}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Inc, + ToB, +} + +#[derive(Clone, Debug, Context)] +struct Ctx { + count: i32, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[derive(PartialEq)] +struct Props { + label: String, +} + +struct Api { + state: S, + count: i32, + label: String, +} + +/// `inc` works in any state; `toB` moves a -> b. The connector is shared so listeners can +/// read it. +fn setup(label: &str) -> (Machine, Rc>) { + let m = build( + Config::::builder(S::A, Ctx { count: 0 }) + .on(EvKind::Inc, |t| { + t.act(|p| Ctx::patch().count(p.context().count + 1)) + }) + .state(S::A, |s| s.on(EvKind::ToB, |t| t.target(S::B))), + ); + let c = Connector::new( + &m, + |snap| Api { + state: snap.view.state(), + count: snap.view.context().count, + label: snap.props.label.clone(), + }, + Props { + label: label.into(), + }, + Vec::new(), + ); + (m, Rc::new(c)) +} + +#[test] +fn the_snapshot_reflects_the_connect_output() { + let (_m, c) = setup("hi"); + let snap = c.snapshot(); + assert_eq!(snap.state, S::A); + assert_eq!(snap.count, 0); + assert_eq!(snap.label, "hi"); +} + +#[test] +fn the_snapshot_is_the_same_rc_while_nothing_changes() { + let (_m, c) = setup("hi"); + assert!(Rc::ptr_eq(&c.snapshot(), &c.snapshot())); +} + +#[test] +fn the_snapshot_changes_after_a_context_change() { + let (m, c) = setup("hi"); + let before = c.snapshot(); + m.send(Ev::Inc); + let after = c.snapshot(); + assert!(!Rc::ptr_eq(&before, &after)); + assert_eq!(after.count, 1); +} + +#[test] +fn the_snapshot_reads_the_live_state() { + let (m, c) = setup("hi"); + m.send(Ev::ToB); + assert_eq!(c.snapshot().state, S::B); +} + +#[test] +fn subscribe_fires_on_any_change_but_not_on_subscribe() { + let (m, c) = setup("hi"); + let calls = Calls::default(); + let sub = c.subscribe(calls.listener()); + assert_eq!(calls.count(), 0); + m.send(Ev::Inc); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + m.send(Ev::Inc); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_listener_unsubscribed_mid_notify_does_not_fire_in_that_pass() { + let (m, c) = setup("hi"); + let log = Log::default(); + let off_b: Rc>> = Rc::default(); + let _ = c.subscribe({ + let (log, off_b) = (log.clone(), off_b.clone()); + move || { + log.push("a"); + if let Some(sub) = off_b.borrow_mut().take() { + sub.unsubscribe(); + } + } + }); + *off_b.borrow_mut() = Some(c.subscribe(log.listener("b"))); + m.send(Ev::Inc); + assert_eq!(log.entries(), ["a"]); +} + +#[test] +fn new_props_rebuild_the_snapshot_and_wake_subscribers() { + let (_m, c) = setup("one"); + let calls = Calls::default(); + let _ = c.subscribe(calls.listener()); + assert_eq!(c.snapshot().label, "one"); + c.set_props(Props { + label: "two".into(), + }); + assert_eq!(c.snapshot().label, "two"); + assert_eq!(calls.count(), 1); +} + +#[test] +fn drives_a_use_sync_external_store_loop_without_churn() { + let (m, c) = setup("hi"); + let renders = Calls::default(); + let read = Rc::new({ + let (c, renders) = (c.clone(), renders.clone()); + move || { + renders.hit(); + c.snapshot() + } + }); + let snap = Rc::new(RefCell::new(read())); + let _ = c.subscribe({ + let (read, snap) = (read.clone(), snap.clone()); + move || { + let next = read(); + assert!( + !Rc::ptr_eq(&next, &snap.borrow()), + "a notify carries a new snapshot" + ); + *snap.borrow_mut() = next; + } + }); + let before = renders.count(); + m.send(Ev::Inc); // one change: one notify, one re-read + assert_eq!(renders.count(), before + 1); +} + +/// Props carrying a callback; equal when they carry the same one. +struct ReactionProps { + on_b: Rc, +} + +impl PartialEq for ReactionProps { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.on_b, &other.on_b) + } +} + +fn recording(log: &Log) -> ReactionProps { + let log = log.clone(); + ReactionProps { + on_b: Rc::new(move |in_b| log.push(in_b)), + } +} + +/// A connector whose reaction calls `props.on_b(in_b)` when "in b" changes. +fn connect(m: &Machine, props: ReactionProps) -> Connector { + let reaction = Reaction::new( + |v| v.matches(AbState::B), + |in_b: &bool, props: &ReactionProps| (props.on_b)(*in_b), + ); + Connector::new(m, |snap| snap.view.state(), props, vec![reaction]) +} + +#[test] +fn a_reaction_fires_on_a_change_once_the_machine_starts() { + let seen = Log::default(); + let m = build(&mut ab()); + let _c = connect(&m, recording(&seen)); + m.start(); + assert!(seen.is_empty()); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [true]); +} + +#[test] +fn reactions_stay_inert_until_the_machine_starts() { + let seen = Log::default(); + let m = build(&mut ab()); + let _c = connect(&m, recording(&seen)); + m.send(AbEvent::ToB); + assert!(seen.is_empty()); +} + +#[test] +fn stop_tears_reactions_down_and_a_restart_re_establishes_them() { + let seen = Log::default(); + let m = build(&mut ab()); + let _c = connect(&m, recording(&seen)); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [true]); + m.stop(); + m.send(AbEvent::ToA); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [true]); + m.start(); + seen.clear(); + m.send(AbEvent::ToA); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [false, true]); +} + +#[test] +fn a_reaction_reads_the_latest_props() { + let first = Log::default(); + let second = Log::default(); + let m = build(&mut ab()); + let c = connect(&m, recording(&first)); + m.start(); + c.set_props(recording(&second)); + m.send(AbEvent::ToB); + assert!(first.is_empty()); + assert_eq!(second.entries(), [true]); +} diff --git a/crates/core/tests/derive.rs b/crates/core/tests/derive.rs new file mode 100644 index 0000000..f5e83fc --- /dev/null +++ b/crates/core/tests/derive.rs @@ -0,0 +1,41 @@ +//! Rust-only: the derive macros name events exactly like serde, so the JS side can send +//! `{ type, ... }` objects that both decoders (serde and `DeserializeEvent`) accept. + +use dunky_core::{DeserializeEvent, Event, EventEnum}; +use serde::Deserialize; + +#[derive(Event, Clone, Debug, PartialEq, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum Picker { + QuerySet { + query: String, + }, + #[serde(rename_all = "camelCase")] + ItemPick { + item_id: u32, + }, + #[serde(rename = "picker.close")] + Close, +} + +#[test] +fn kind_names_follow_serde_renames() { + assert_eq!( + Picker::KIND_NAMES, + ["query_set", "item_pick", "picker.close"] + ); + let parsed: Picker = + serde_json::from_value(serde_json::json!({ "type": "query_set", "query": "a" })).unwrap(); + assert_eq!(parsed, Picker::QuerySet { query: "a".into() }); +} + +#[test] +fn payload_decoding_follows_the_variant_field_renames() { + let kind = Picker::kind_index(PickerKind::ItemPick); + let event = Picker::deserialize_payload( + kind, + serde_json::json!({ "type": "item_pick", "itemId": 3 }), + ); + assert_eq!(event.unwrap(), Picker::ItemPick { item_id: 3 }); +} diff --git a/crates/core/tests/effects.rs b/crates/core/tests/effects.rs new file mode 100644 index 0000000..892f211 --- /dev/null +++ b/crates/core/tests/effects.rs @@ -0,0 +1,257 @@ +//! Effects: started when their state is entered (and, on start, for the current state), +//! cleaned up when it is left or the machine stops. +//! +//! Not ported: +//! - "does NOT start effects at construction; starts them on start()": the same test as +//! lifecycle.rs (is_built_stopped_and_start_boots_the_effects). +//! - "stop() runs the active effect cleanups": covered by lifecycle.rs +//! (stop_runs_the_cleanups_and_is_idempotent). +//! - "cleanup of the initial effect runs on the first transition out": covered by +//! lifecycle.rs (is_built_stopped_and_start_boots_the_effects and +//! while_running_a_transition_cleans_up_the_old_effects_then_starts_the_new). +//! - "is restartable — start after stop re-boots the initial effect": covered by +//! lifecycle.rs (a_restart_boots_the_state_the_machine_is_in). +//! - "effect closures read LIVE context across later writes": effect params are borrowed +//! for the call, so no closure can keep them past it (the borrow checker rejects it); +//! there is no captured context reference that could go stale. + +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use common::{Ab, AbEvent, AbState, Count, Counter, CounterState, Log, ab, build}; +use dunky_core::{ + ActionParams, Cleanup, Config, Context, Event, EventEnum, MACHINE_INIT, State, Types, +}; + +#[test] +fn runs_the_effect_on_enter_and_its_cleanup_on_exit() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(Some("start:b"), Some("cleanup:b"))) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(log.entries(), ["start:b"]); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["start:b", "cleanup:b"]); +} + +#[test] +fn the_cleanup_runs_before_exit_actions() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(None, Some("cleanup"))) + .exit_run(log.action("exit-action")) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["cleanup", "exit-action"]); +} + +#[test] +fn an_effect_starts_after_entry_actions() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.entry_run(log.action("entry-action")) + .effect_run(log.effect(Some("start"), None)) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(log.entries(), ["entry-action", "start"]); +} + +#[test] +fn an_effect_may_return_no_cleanup() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(Some("fire-and-forget"), None)) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["fire-and-forget"]); + assert_eq!(m.state(), AbState::A); +} + +#[test] +fn resolves_an_effect_by_name() { + let log = Log::default(); + let mut b = ab(); + b.effect( + "watch", + log.effect(Some("watch:start"), Some("watch:cleanup")), + ) + .state(AbState::B, |s| s.effect("watch")); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["watch:start", "watch:cleanup"]); +} + +#[test] +fn every_effect_of_the_state_is_cleaned_up_on_exit() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(None, Some("c1"))) + .effect_run(log.effect(None, Some("c2"))) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["c1", "c2"]); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no effect \"missing\"")] +fn panics_in_debug_when_an_effect_name_is_not_registered() { + let mut b = ab(); + b.state(AbState::B, |s| s.effect("missing")); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); +} + +#[test] +fn a_panicking_cleanup_still_runs_the_others_and_clears_the_pass() { + let log = Log::default(); + let panicking = { + let log = log.clone(); + move |_: &mut ActionParams<'_, Ab>| { + let log = log.clone(); + let cleanup: Cleanup = Box::new(move || { + log.push("c1"); + panic!("boom"); + }); + Some(cleanup) + } + }; + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(panicking) + .effect_run(log.effect(None, Some("c2"))) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + let payload = catch_unwind(AssertUnwindSafe(|| m.send(AbEvent::ToA))).unwrap_err(); + assert_eq!(payload.downcast_ref::<&str>(), Some(&"boom")); + assert_eq!(log.entries(), ["c1", "c2"]); + m.stop(); // the failed pass already ran every cleanup: nothing runs twice + assert_eq!(log.entries(), ["c1", "c2"]); +} + +struct Labeled; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum LabeledState { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum LabeledEvent { + ToB, + Mark, +} + +#[derive(Clone, Debug, Context)] +struct Label { + label: String, +} + +impl Types for Labeled { + type State = LabeledState; + type Event = LabeledEvent; + type Context = Label; +} + +#[test] +fn an_effect_can_read_the_context_and_queue_events() { + let log = Log::default(); + let m = build( + Config::::builder( + LabeledState::A, + Label { + label: "hello".into(), + }, + ) + .state(LabeledState::A, |s| { + s.on(LabeledEventKind::ToB, |t| t.target(LabeledState::B)) + }) + .state(LabeledState::B, |s| { + let effect_log = log.clone(); + s.effect_run(move |p| { + effect_log.push(p.context().label.clone()); + p.send(LabeledEvent::Mark); + None + }) + .on(LabeledEventKind::Mark, |t| t.run(log.action("marked"))) + }), + ); + m.start(); + m.send(LabeledEvent::ToB); + assert_eq!(log.entries(), ["hello", "marked"]); +} + +#[test] +fn a_boot_effect_sees_the_machine_init_marker() { + let seen: Log<&'static str> = Log::default(); + let mut b = ab(); + b.state(AbState::A, |s| { + let seen = seen.clone(); + s.effect_run(move |p| { + seen.push(p.event().map_or(MACHINE_INIT, |e| e.type_name())); + None + }) + }); + let m = build(&mut b); + m.start(); + assert_eq!(seen.entries(), ["machine.init"]); + assert_eq!(MACHINE_INIT, "machine.init"); +} + +#[test] +fn start_runs_the_initial_effects_but_not_the_initial_entry() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::A, |s| { + s.entry_run(log.action("entry")) + .effect_run(log.effect(Some("effect"), None)) + }); + let m = build(&mut b); + m.start(); + assert_eq!(log.entries(), ["effect"]); +} + +#[test] +fn an_effect_can_read_the_context_at_start() { + let seen: Log = Log::default(); + let m = build( + Config::::builder(CounterState::Idle, Count { n: 7 }).state( + CounterState::Idle, + |s| { + let seen = seen.clone(); + s.effect_run(move |p| { + seen.push(p.context().n); + None + }) + }, + ), + ); + m.start(); + assert_eq!(seen.entries(), [7]); +} diff --git a/crates/core/tests/guards.rs b/crates/core/tests/guards.rs new file mode 100644 index 0000000..05ed8ab --- /dev/null +++ b/crates/core/tests/guards.rs @@ -0,0 +1,229 @@ +//! Guards gate transitions: inline predicates, named ones from the builder's registry, +//! and the and / or / not combinators. +//! +//! Not ported: +//! - "guard params include `computed` (empty when no computed configured)": JS params +//! carry a `computed` object; Rust reads computed values by typed key, so there is no +//! such object to inspect (a guard reading a computed value is pinned in computed.rs). + +mod common; + +use common::{Calls, build}; +use dunky_core::{Config, ConfigBuilder, Context, Event, Guard, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + Idle, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Go, + Add { by: i32 }, + Push { force: bool }, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + allow: bool, + n: i32, + a: bool, + b: bool, + locked: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +/// Every named guard the tests reference. +fn registry(b: &mut ConfigBuilder) -> &mut ConfigBuilder { + b.guard("isAllowed", |p| p.context().allow) + .guard("never", |_| false) + .guard("isA", |p| p.context().a) + .guard("isB", |p| p.context().b) + .guard("isLocked", |p| p.context().locked) + .guard("isTwo", |p| p.context().n == 2) + .guard("isThree", |p| p.context().n == 3) + .guard("isOdd", |p| p.context().n % 2 == 1) +} + +/// Whether `go` is taken under `guard`, against `ctx` and the registry. +fn go_taken(ctx: Ctx, guard: Guard) -> bool { + let ran = Calls::default(); + let mut b = Config::::builder(S::Idle, ctx); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Go, |t| t.guard_expr(guard).run(ran.action())) + }); + build(&mut b).send(Ev::Go); + ran.count() == 1 +} + +#[test] +fn an_inline_guard_gates_the_transition() { + let m = build( + Config::::builder( + S::Idle, + Ctx { + allow: true, + ..Ctx::default() + }, + ) + .state(S::Idle, |s| { + s.on(EvKind::Go, |t| { + t.guard_fn(|p| p.context().allow) + .act(|p| Ctx::patch().n(p.context().n + 1).allow(false)) + }) + }), + ); + m.send(Ev::Go); // allowed: runs, then disallows itself + assert_eq!(m.context().n, 1); + m.send(Ev::Go); // blocked + assert_eq!(m.context().n, 1); +} + +#[test] +fn a_guard_can_read_the_event_payload() { + let m = build( + Config::::builder(S::Idle, Ctx::default()).state(S::Idle, |s| { + s.on(EvKind::Add, |t| { + t.guard_fn(|p| matches!(p.event(), Some(Ev::Add { by }) if *by > 0)) + .act(|p| { + let Some(Ev::Add { by }) = p.event() else { + unreachable!() + }; + Ctx::patch().n(p.context().n + by) + }) + }) + }), + ); + m.send(Ev::Add { by: 5 }); + assert_eq!(m.context().n, 5); + m.send(Ev::Add { by: -3 }); // only positive additions pass + assert_eq!(m.context().n, 5); +} + +#[test] +fn resolves_a_guard_by_name() { + let mut b = Config::::builder( + S::Idle, + Ctx { + allow: true, + ..Ctx::default() + }, + ); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Go, |t| { + t.guard("isAllowed") + .act(|p| Ctx::patch().n(p.context().n + 1).allow(false)) + }) + }); + let m = build(&mut b); + m.send(Ev::Go); + assert_eq!(m.context().n, 1); + m.send(Ev::Go); + assert_eq!(m.context().n, 1); +} + +#[test] +fn named_and_inline_guards_coexist_in_a_fallthrough_list() { + let mut b = Config::::builder(S::Idle, Ctx::default()); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Go, |t| t.guard("never").act(|_| Ctx::patch().n(99))) + .on(EvKind::Go, |t| { + t.guard_fn(|p| p.context().n < 3) + .act(|p| Ctx::patch().n(p.context().n + 1)) + }) + }); + let m = build(&mut b); + m.send(Ev::Go); // `never` fails, the inline guard passes + assert_eq!(m.context().n, 1); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no guard \"missing\"")] +fn panics_in_debug_when_a_guard_name_is_not_registered() { + let m = build( + Config::::builder(S::Idle, Ctx::default()) + .state(S::Idle, |s| s.on(EvKind::Go, |t| t.guard("missing"))), + ); + m.send(Ev::Go); +} + +#[test] +fn and_passes_when_every_guard_passes() { + let ctx = Ctx { + a: true, + b: true, + ..Ctx::default() + }; + assert!(go_taken(ctx, Guard::and(["isA".into(), "isB".into()]))); +} + +#[test] +fn and_blocks_when_one_guard_fails() { + let ctx = Ctx { + a: true, + b: false, + ..Ctx::default() + }; + assert!(!go_taken(ctx, Guard::and(["isA".into(), "isB".into()]))); +} + +#[test] +fn or_passes_when_any_guard_passes_and_not_negates() { + let ran = Calls::default(); + let mut b = Config::::builder( + S::Idle, + Ctx { + locked: true, + ..Ctx::default() + }, + ); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Push, |t| { + t.guard_expr(Guard::or([ + Guard::::when(|p| matches!(p.event(), Some(Ev::Push { force: true }))), + Guard::not("isLocked".into()), + ])) + .run(ran.action()) + }) + }); + let m = build(&mut b); + m.send(Ev::Push { force: false }); // locked and not forced + assert_eq!(ran.count(), 0); + m.send(Ev::Push { force: true }); + assert_eq!(ran.count(), 1); +} + +#[test] +fn combinators_accept_inline_guards() { + let ctx = Ctx { + n: 5, + ..Ctx::default() + }; + let guard = Guard::and([ + Guard::::when(|p| p.context().n > 0), + Guard::not(Guard::::when(|p| p.context().n > 100)), + ]); + assert!(go_taken(ctx, guard)); +} + +#[test] +fn combinators_nest() { + let ctx = Ctx { + n: 2, + ..Ctx::default() + }; + // or(isTwo, isThree) = true; and(isTwo, isOdd) = false; not(false) = true + let guard = Guard::and([ + Guard::or(["isTwo".into(), "isThree".into()]), + Guard::not(Guard::and(["isTwo".into(), "isOdd".into()])), + ]); + assert!(go_taken(ctx, guard)); +} diff --git a/crates/core/tests/lifecycle.rs b/crates/core/tests/lifecycle.rs new file mode 100644 index 0000000..286c879 --- /dev/null +++ b/crates/core/tests/lifecycle.rs @@ -0,0 +1,116 @@ +//! Lifecycle: a machine is built stopped; start boots the current state's effects and +//! stop runs their cleanups, both idempotent; events still work while stopped. + +mod common; + +use common::{Ab, AbEvent, AbEventKind, AbState, Calls, Log, ab, build}; +use dunky_core::Config; + +#[test] +fn is_built_stopped_and_start_boots_the_effects() { + let fx = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(fx.effect(None))); + let m = build(&mut b); + assert_eq!(fx.count(), 0); + m.start(); + assert_eq!(fx.count(), 1); +} + +#[test] +fn start_is_idempotent() { + let fx = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(fx.effect(None))); + let m = build(&mut b); + m.start(); + m.start(); + assert_eq!(fx.count(), 1); +} + +#[test] +fn stop_runs_the_cleanups_and_is_idempotent() { + let fx = Calls::default(); + let cleanup = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(fx.effect(Some(&cleanup)))); + let m = build(&mut b); + m.start(); + m.stop(); + assert_eq!(cleanup.count(), 1); + m.stop(); + assert_eq!(cleanup.count(), 1); +} + +#[test] +fn events_work_while_stopped_but_effects_do_not_run() { + let log = Log::default(); + let m = build( + Config::::builder(AbState::A, ()) + .state(AbState::A, |s| { + s.exit_run(log.action("exit-action")) + .effect_run(log.effect(None, Some("effect-cleanup"))) + .on(AbEventKind::ToB, |t| { + t.target(AbState::B).run(log.action("transition-action")) + }) + }) + .state(AbState::B, |s| { + s.entry_run(log.action("entry-action")) + .effect_run(log.effect(Some("effect-start"), None)) + }), + ); + m.send(AbEvent::ToB); // never started + assert_eq!(m.state(), AbState::B); + assert_eq!( + log.entries(), + ["exit-action", "transition-action", "entry-action"] + ); +} + +#[test] +fn start_boots_the_current_state_after_moving_while_stopped() { + let a_fx = Calls::default(); + let b_fx = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(a_fx.effect(None))) + .state(AbState::B, |s| s.effect_run(b_fx.effect(None))); + let m = build(&mut b); + m.send(AbEvent::ToB); + m.start(); + assert_eq!(a_fx.count(), 0); + assert_eq!(b_fx.count(), 1); +} + +#[test] +fn a_restart_boots_the_state_the_machine_is_in() { + let a_fx = Calls::default(); + let b_fx = Calls::default(); + let b_cleanup = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(a_fx.effect(None))) + .state(AbState::B, |s| s.effect_run(b_fx.effect(Some(&b_cleanup)))); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.stop(); + assert_eq!(b_cleanup.count(), 1); + m.start(); // still in b + assert_eq!(a_fx.count(), 1); + assert_eq!(b_fx.count(), 2); +} + +#[test] +fn while_running_a_transition_cleans_up_the_old_effects_then_starts_the_new() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::A, |s| { + s.effect_run(log.effect(None, Some("a:cleanup"))) + }) + .state(AbState::B, |s| { + s.effect_run(log.effect(Some("b:start"), None)) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(log.entries(), ["a:cleanup", "b:start"]); +} diff --git a/crates/core/tests/setup.rs b/crates/core/tests/setup.rs new file mode 100644 index 0000000..e50a929 --- /dev/null +++ b/crates/core/tests/setup.rs @@ -0,0 +1,89 @@ +//! The builder's registries (`guard`, `action`, `effect`, `delay`): the runtime half of +//! TS `setup`. Named references resolve when the config is built. +//! +//! Not ported (TypeScript type-level cases with no Rust equivalent): +//! - "infer path: setup.infer().createMachine(literal) builds a valid config, types +//! inferred": a Rust config is always typed by its `Types` bundle. +//! - "checks names at compile time (the @ts-expect-error blocks are the test)": Rust names +//! are strings resolved at build; a missing one fails when it runs (the "panics in +//! debug" tests in guards.rs, actions.rs, effects.rs and after.rs). +//! - "valid names + numeric delays coexist, and inline fns still work": a compile-time +//! check; its runtime content (named and fixed delays, named and inline guards) is +//! covered below and in guards.rs. + +mod common; + +use common::{Calls, Log, build}; +use dunky_core::testing::Clock; +use dunky_core::{Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + Closed, + Open, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Open, + Close, +} + +#[derive(Clone, Debug, Context)] +struct Ctx { + id: String, + open_ms: u32, + open: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn named_implementations_resolve_at_runtime() { + let set_id: Log = Log::default(); + let track = Calls::default(); + let m = build( + Config::::builder( + S::Closed, + Ctx { + id: "a".into(), + open_ms: 5, + open: false, + }, + ) + .guard("isOpen", |p| p.context().open) + .action("setId", { + let set_id = set_id.clone(); + move |p| set_id.push(p.context().id.clone()) + }) + .effect("track", track.effect(None)) + .delay("openDelay", |p| p.context().open_ms) + .state(S::Closed, |s| s.on(EvKind::Open, |t| t.target(S::Open))) + .state(S::Open, |s| { + s.entry("setId") + .effect("track") + .after("openDelay", |t| t.target(S::Closed)) + .after(200, |t| t.target(S::Closed)) + .on(EvKind::Close, |t| t.target(S::Closed).guard("isOpen")) + }), + ); + m.start(); + assert_eq!(m.state(), S::Closed); + m.send(Ev::Open); + assert_eq!(m.state(), S::Open); + assert_eq!(set_id.entries(), ["a"]); + assert_eq!(track.count(), 1); + m.send(Ev::Close); // `isOpen` reads open = false + assert_eq!(m.state(), S::Open); + let mut clock = Clock::new(&m); + clock.advance(4); // `openDelay` reads open_ms = 5 + assert_eq!(m.state(), S::Open); + clock.advance(1); + assert_eq!(m.state(), S::Closed); +} diff --git a/crates/core/tests/store.rs b/crates/core/tests/store.rs new file mode 100644 index 0000000..4de1eed --- /dev/null +++ b/crates/core/tests/store.rs @@ -0,0 +1,90 @@ +//! Store: a tiny reactive cell for state shared between machine instances. Updates +//! shallow-merge and notify only on an actual change. +//! +//! Not ported: +//! - "builder adds named methods alongside the base, with store access": a JS builder +//! API; in Rust, domain methods are ordinary functions or impls over a `Store`. + +mod common; + +use std::cell::RefCell; +use std::rc::Rc; + +use common::{Calls, Count, Log}; +use dunky_core::{Context, Store, Subscription}; + +#[derive(Clone, Debug, PartialEq, Context)] +struct Pair { + a: i32, + b: i32, +} + +#[test] +fn set_and_update_shallow_merge() { + let store = Store::new(Pair { a: 1, b: 2 }); + assert_eq!(*store.get(), Pair { a: 1, b: 2 }); + store.set(Pair::patch().a(9)); + assert_eq!(*store.get(), Pair { a: 9, b: 2 }); + store.update(|s| Pair::patch().b(s.b + 1)); + assert_eq!(*store.get(), Pair { a: 9, b: 3 }); +} + +#[test] +fn subscribe_fires_on_a_change_but_not_on_subscribe() { + let store = Store::new(Count { n: 0 }); + let calls = Calls::default(); + let sub = store.subscribe({ + let calls = calls.clone(); + move |_| calls.hit() + }); + assert_eq!(calls.count(), 0); + store.set(Count::patch().n(1)); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + store.set(Count::patch().n(2)); + assert_eq!(calls.count(), 1); +} + +#[test] +fn an_effective_set_yields_a_fresh_value() { + let store = Store::new(Count { n: 0 }); + let before = store.get(); + store.set(Count::patch().n(1)); + assert!(!Rc::ptr_eq(&before, &store.get())); +} + +#[test] +fn a_listener_unsubscribed_mid_notify_does_not_fire_in_that_pass() { + let store = Store::new(Count { n: 0 }); + let log: Log = Log::default(); + let off_b: Rc>> = Rc::default(); + let _ = store.subscribe({ + let (log, off_b) = (log.clone(), off_b.clone()); + move |_| { + log.push("a"); + if let Some(sub) = off_b.borrow_mut().take() { + sub.unsubscribe(); + } + } + }); + *off_b.borrow_mut() = Some(store.subscribe({ + let log = log.clone(); + move |_| log.push("b") + })); + store.set(Count::patch().n(1)); + assert_eq!(log.entries(), ["a"]); +} + +#[test] +fn a_set_that_changes_nothing_is_silent() { + let store = Store::new(Count { n: 5 }); + let calls = Calls::default(); + let _ = store.subscribe({ + let calls = calls.clone(); + move |_| calls.hit() + }); + store.set(Count::patch().n(5)); + assert_eq!(calls.count(), 0); + store.set(Count::patch().n(6)); + assert_eq!(calls.count(), 1); +} diff --git a/crates/core/tests/subscribe.rs b/crates/core/tests/subscribe.rs new file mode 100644 index 0000000..0bf1041 --- /dev/null +++ b/crates/core/tests/subscribe.rs @@ -0,0 +1,241 @@ +//! Observation: the coarse `subscribe` (wakes on any change) and value-deduped +//! selections (`select`, `select_computed`, `select_state`). Neither fires on subscribe. +//! +//! Not ported: +//! - "the select facade is a stable identity across accesses": a JS facade object; Rust's +//! `select` is a method. +//! - "without equals, a fresh-object selector fires every change (Object.is)": identity +//! semantics. Rust selections compare with `PartialEq`, which the value-dedup test pins. +//! - "select.context(key) selects one field with the exact value type" and "select(fn) +//! function form still works alongside the scope methods": JS facade shapes. Rust's +//! `Field` handles carry no value type, so a one-field selection is +//! `select(|v| v.context().x)`, the selection the tests below pin. +//! - ".value reads the current selected value": the first of two such TS tests; the +//! second ("on demand") covers it. +//! - "does NOT fire on subscribe; fires on a context change", "multiple subscribers all +//! fire" and the "reentrancy" group (subscribing or unsubscribing during a notify, +//! nested notify): the same bus behind the same `Machine::subscribe`, pinned in +//! broadcast.rs. + +mod common; + +use common::{ + AbEvent, AbState, Calls, Count, Counter, CounterEvent, CounterEventKind, CounterState, Log, ab, + build, counter, +}; +use dunky_core::{Config, Context, Event, State, Types}; + +/// A machine whose events write `x`, `y` and `other`. +struct Plane; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum PlaneState { + Idle, +} + +#[derive(Clone, Debug, Event)] +enum PlaneEvent { + MoveX, + BumpOther, + Both, +} + +#[derive(Clone, Debug, Default, Context)] +struct Pos { + x: i32, + y: i32, + other: i32, +} + +impl Types for Plane { + type State = PlaneState; + type Event = PlaneEvent; + type Context = Pos; +} + +fn plane() -> dunky_core::Machine { + build( + Config::::builder(PlaneState::Idle, Pos::default()).state(PlaneState::Idle, |s| { + s.on(PlaneEventKind::MoveX, |t| { + t.act(|p| Pos::patch().x(p.context().x + 1)) + }) + .on(PlaneEventKind::BumpOther, |t| { + t.act(|p| Pos::patch().other(p.context().other + 1)) + }) + .on(PlaneEventKind::Both, |t| t.act(|_| Pos::patch().x(1).y(1))) + }), + ) +} + +#[test] +fn subscribe_fires_on_a_state_change() { + let m = build(&mut ab()); + let calls = Calls::default(); + let _ = m.subscribe(calls.listener()); + m.send(AbEvent::ToB); + assert_eq!(calls.count(), 1); +} + +#[test] +fn unsubscribe_stops_notifications() { + let m = build(&mut counter()); + let calls = Calls::default(); + let sub = m.subscribe(calls.listener()); + m.send(CounterEvent::Inc); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + m.send(CounterEvent::Inc); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_multi_field_patch_notifies_once() { + let m = plane(); + let calls = Calls::default(); + let _ = m.subscribe(calls.listener()); + m.send(PlaneEvent::Both); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_patch_that_changes_nothing_is_silent() { + let m = build( + Config::::builder(CounterState::Idle, Count { n: 5 }) + .state(CounterState::Idle, |s| { + s.on(CounterEventKind::Inc, |t| t.act(|_| Count::patch().n(5))) + }), + ); + let calls = Calls::default(); + let _ = m.subscribe(calls.listener()); + m.send(CounterEvent::Inc); + assert_eq!(calls.count(), 0); +} + +#[test] +fn a_selection_fires_with_the_new_value_but_not_on_subscribe() { + let m = build(&mut counter()); + let seen: Log = Log::default(); + let _ = m.select(|v| v.context().n).subscribe({ + let seen = seen.clone(); + move |n| seen.push(*n) + }); + assert!(seen.is_empty()); + m.send(CounterEvent::Inc); + assert_eq!(seen.entries(), [1]); +} + +#[test] +fn a_selection_is_silent_while_the_selected_value_is_unchanged() { + let m = build(&mut counter()); + let seen: Log = Log::default(); + let _ = m.select(|v| v.context().n == 0).subscribe({ + let seen = seen.clone(); + move |is_zero| seen.push(*is_zero) + }); + m.send(CounterEvent::Inc); // true -> false: fires + assert_eq!(seen.entries(), [false]); + m.send(CounterEvent::Inc); // false -> false: silent + assert_eq!(seen.entries(), [false]); +} + +#[test] +fn unsubscribing_a_selection_stops_it() { + let m = build(&mut counter()); + let seen: Log = Log::default(); + let sub = m.select(|v| v.context().n).subscribe({ + let seen = seen.clone(); + move |n| seen.push(*n) + }); + m.send(CounterEvent::Inc); + sub.unsubscribe(); + m.send(CounterEvent::Inc); + assert_eq!(seen.entries(), [1]); +} + +/// A selected value with no `PartialEq`, so only a supplied equality can dedup it. +struct Point { + x: i32, + y: i32, +} + +#[test] +fn a_supplied_equality_decides_what_counts_as_a_change() { + let m = plane(); + let seen: Log<(i32, i32)> = Log::default(); + let _ = m + .select(|v| Point { + x: v.context().x, + y: v.context().y, + }) + .subscribe_with( + { + let seen = seen.clone(); + move |p| seen.push((p.x, p.y)) + }, + |a, b| a.x == b.x && a.y == b.y, + ); + m.send(PlaneEvent::BumpOther); // {x, y} unchanged by the equality: silent + assert!(seen.is_empty()); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [(1, 0)]); +} + +#[test] +fn value_reads_the_current_selection_on_demand() { + let m = build(&mut counter()); + let n = m.select(|v| v.context().n); + assert_eq!(n.value(), 0); + m.send(CounterEvent::Inc); + assert_eq!(n.value(), 1); + m.send(CounterEvent::Inc); + assert_eq!(n.value(), 2); +} + +#[test] +fn select_computed_selects_a_derived_value() { + let mut b = counter(); + let is_zero = b.computed("isZero", |p| *p.context.n() == 0); + let m = build(&mut b); + let selection = m.select_computed(is_zero); + assert!(*selection.value()); + let seen: Log = Log::default(); + let _ = selection.subscribe({ + let seen = seen.clone(); + move |v| seen.push(**v) + }); + m.send(CounterEvent::Inc); // true -> false: fires + m.send(CounterEvent::Inc); // false -> false: silent + assert_eq!(seen.entries(), [false]); +} + +#[test] +fn select_state_fires_on_transitions() { + let m = build(&mut ab()); + let state = m.select_state(); + assert_eq!(state.value(), AbState::A); + let seen: Log = Log::default(); + let _ = state.subscribe({ + let seen = seen.clone(); + move |s| seen.push(*s) + }); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(seen.entries(), [AbState::B, AbState::A]); +} + +#[test] +fn a_selection_listener_may_patch_the_machine_it_observes() { + let m = plane(); + let seen: Log = Log::default(); + let _ = m.select(|v| v.context().x).subscribe({ + let (m, seen) = (m.clone(), seen.clone()); + move |x| { + seen.push(*x); + if *x == 1 { + m.set_context(Pos::patch().x(5)); // notifies again before this call returns + } + } + }); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [1, 5]); +} diff --git a/crates/core/tests/transitions.rs b/crates/core/tests/transitions.rs new file mode 100644 index 0000000..a5afd78 --- /dev/null +++ b/crates/core/tests/transitions.rs @@ -0,0 +1,180 @@ +//! Transitions: per-state and any-state handlers, guard fallthrough, targetless +//! self-transitions, and the queued (run-to-completion) send. +//! +//! Port note: the TS "events from outside also serialize" case sends from an action, +//! which duplicates the re-entrant-send case; here it sends from an observer instead, +//! which is what its title describes. + +mod common; + +use common::{Calls, Count, Counter, CounterEvent, CounterEventKind, CounterState, Log, build}; +use dunky_core::{Config, ConfigBuilder, Event, State, Types}; + +struct Door; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum DoorState { + Closed, + Open, + Gone, +} + +#[derive(Clone, Debug, Event)] +enum DoorEvent { + Open, + Close, + AutoClose, + Kill, +} + +impl Types for Door { + type State = DoorState; + type Event = DoorEvent; + type Context = (); +} + +/// closed --open--> open --close--> closed +fn door() -> ConfigBuilder { + let mut b = Config::::builder(DoorState::Closed, ()); + b.state(DoorState::Closed, |s| { + s.on(DoorEventKind::Open, |t| t.target(DoorState::Open)) + }) + .state(DoorState::Open, |s| { + s.on(DoorEventKind::Close, |t| t.target(DoorState::Closed)) + }); + b +} + +#[test] +fn moves_between_states_on_a_matching_event() { + let m = build(&mut door()); + assert_eq!(m.state(), DoorState::Closed); + m.send(DoorEvent::Open); + assert_eq!(m.state(), DoorState::Open); + m.send(DoorEvent::Close); + assert_eq!(m.state(), DoorState::Closed); +} + +#[test] +fn ignores_an_event_the_current_state_does_not_handle() { + let m = build(&mut door()); + m.send(DoorEvent::Close); + assert_eq!(m.state(), DoorState::Closed); +} + +#[test] +fn any_state_handlers_work_from_every_state_and_a_state_handler_takes_precedence() { + let log = Log::default(); + let mut b = door(); + b.on(DoorEventKind::Kill, |t| t.target(DoorState::Gone)) + .state(DoorState::Closed, |s| { + s.on(DoorEventKind::Kill, |t| { + t.run(log.action("closed handled kill")) + }) + }); + let m = build(&mut b); + m.send(DoorEvent::Kill); // closed has its own handler: it wins + assert_eq!(m.state(), DoorState::Closed); + assert_eq!(log.entries(), ["closed handled kill"]); + m.send(DoorEvent::Open); + m.send(DoorEvent::Kill); // open has none: the any-state handler applies + assert_eq!(m.state(), DoorState::Gone); +} + +#[test] +fn guard_fallthrough_takes_the_first_candidate_whose_guard_passes() { + let m = build( + Config::::builder(CounterState::Idle, Count { n: 5 }).state( + CounterState::Idle, + |s| { + s.on(CounterEventKind::Inc, |t| { + t.guard_fn(|p| p.context().n > 10) + .act(|_| Count::patch().n(999)) + }) + .on(CounterEventKind::Inc, |t| { + t.guard_fn(|p| p.context().n > 0) + .act(|_| Count::patch().n(1)) + }) + .on(CounterEventKind::Inc, |t| t.act(|_| Count::patch().n(-1))) + }, + ), + ); + m.send(CounterEvent::Inc); // n=5: first guard fails, second passes + assert_eq!(m.context().n, 1); +} + +#[test] +fn a_targetless_transition_runs_its_actions_and_stays_in_the_state() { + let ran = Calls::default(); + let m = build( + Config::::builder(CounterState::Idle, Count { n: 0 }).state( + CounterState::Idle, + |s| { + s.on(CounterEventKind::Inc, |t| { + t.run(ran.action()) + .act(|p| Count::patch().n(p.context().n + 1)) + }) + }, + ), + ); + m.send(CounterEvent::Inc); + m.send(CounterEvent::Inc); + assert_eq!(ran.count(), 2); + assert_eq!(m.context().n, 2); + assert_eq!(m.state(), CounterState::Idle); +} + +#[test] +fn a_send_from_an_action_runs_after_the_current_transition_completes() { + let log = Log::default(); + let m = build( + Config::::builder(DoorState::Closed, ()) + .state(DoorState::Closed, |s| { + s.on(DoorEventKind::Open, |t| { + let log = log.clone(); + t.target(DoorState::Open).run(move |p| { + log.push("announce"); + p.send(DoorEvent::AutoClose); + }) + }) + }) + .state(DoorState::Open, |s| { + s.on(DoorEventKind::AutoClose, |t| { + t.target(DoorState::Closed).run(log.action("closed")) + }) + }), + ); + m.send(DoorEvent::Open); + // autoClose was processed against `open` (the new state), so it matched. + assert_eq!(log.entries(), ["announce", "closed"]); + assert_eq!(m.state(), DoorState::Closed); +} + +#[test] +fn a_send_from_an_observer_mid_transition_waits_for_the_transition_to_complete() { + let log = Log::default(); + let m = build( + Config::::builder(DoorState::Closed, ()) + .state(DoorState::Closed, |s| { + s.on(DoorEventKind::Open, |t| t.target(DoorState::Open)) + }) + .state(DoorState::Open, |s| { + s.entry_run(log.action("entry:open")) + .on(DoorEventKind::AutoClose, |t| { + t.target(DoorState::Closed).run(log.action("auto-closed")) + }) + }), + ); + let _ = m.subscribe({ + let m = m.clone(); + move || { + if m.state() == DoorState::Open { + m.send(DoorEvent::AutoClose); + } + } + }); + m.send(DoorEvent::Open); + // The observer fired at the switch to `open`, before its entry actions. + assert_eq!(log.entries(), ["entry:open", "auto-closed"]); + assert_eq!(m.state(), DoorState::Closed); +} diff --git a/crates/core/tests/watch.rs b/crates/core/tests/watch.rs new file mode 100644 index 0000000..57a090f --- /dev/null +++ b/crates/core/tests/watch.rs @@ -0,0 +1,329 @@ +//! `watch`: data-reactions on a context field or a computed value. Their actions run, +//! deferred onto the queue, when the value changes, in any state, only while running. +//! +//! Port note: the TS "watches a computed field" case only checks that an unchanged +//! computed value stays silent; this port also checks that a changed one fires. +//! +//! Like the TS engine, a write is observed as it happens: the last four tests pin that a +//! watcher run is queued at the write itself, in order with the sends around it. + +mod common; + +use common::{Log, build}; +use dunky_core::{Action, Config, ConfigBuilder, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Bump, + BumpOther, + BumpA, + BumpB, + Add, + Clear, + Toggle, + SetN(i32), + Go, + Ping, + ToB, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + n: i32, + other: i32, + a: i32, + b: i32, + items: Vec, + flag: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +/// Any-state handlers write the context; `toB` moves a -> b. Tests add their watchers. +fn builder(ctx: Ctx) -> ConfigBuilder { + let mut b = Config::::builder(S::A, ctx); + b.on(EvKind::Bump, |t| { + t.act(|p| Ctx::patch().n(p.context().n + 1)) + }) + .on(EvKind::BumpOther, |t| { + t.act(|p| Ctx::patch().other(p.context().other + 1)) + }) + .on(EvKind::BumpA, |t| { + t.act(|p| Ctx::patch().a(p.context().a + 1)) + }) + .on(EvKind::BumpB, |t| { + t.act(|p| Ctx::patch().b(p.context().b + 1)) + }) + .on(EvKind::Add, |t| { + t.act(|p| { + let mut items = p.context().items.clone(); + items.push(1); + Ctx::patch().items(items) + }) + }) + .on(EvKind::Clear, |t| t.act(|_| Ctx::patch().items(Vec::new()))) + .on(EvKind::Toggle, |t| { + t.act(|p| Ctx::patch().flag(!p.context().flag)) + }) + .on(EvKind::SetN, |t| { + t.act(|p| match p.event() { + Some(Ev::SetN(n)) => Ctx::patch().n(*n), + _ => Ctx::patch(), + }) + }) + .state(S::A, |s| s.on(EvKind::ToB, |t| t.target(S::B))); + b +} + +/// A watcher action that records `n`. +fn record_n(seen: &Log) -> Action { + let seen = seen.clone(); + Action::::run(move |p| seen.push(p.context().n)) +} + +#[test] +fn runs_when_a_watched_field_changes_but_not_on_start() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.start(); + assert!(seen.is_empty()); + m.send(Ev::Bump); + m.send(Ev::Bump); + assert_eq!(seen.entries(), [1, 2]); +} + +#[test] +fn ignores_writes_to_other_fields() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [Action::::run(log.action("fired"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::BumpOther); + assert!(log.is_empty()); + m.send(Ev::Bump); + assert_eq!(log.entries(), ["fired"]); +} + +#[test] +fn watches_a_computed_value() { + let seen: Log = Log::default(); + let mut b = builder(Ctx { + items: vec![1], + ..Ctx::default() + }); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.watch_computed( + is_empty, + [Action::::run({ + let seen = seen.clone(); + move |p| seen.push(*p.computed(is_empty)) + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::Add); // items changed, isEmpty stayed false: silent + assert!(seen.is_empty()); + m.send(Ev::Clear); + assert_eq!(seen.entries(), [true]); +} + +#[test] +fn reacts_in_any_state_and_can_send() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Ping, |t| t.run(log.action("ping"))) + .watch(Ctx::FLAG, [Action::::run(|p| p.send(Ev::Ping))]); + let m = build(&mut b); + m.start(); + m.send(Ev::Toggle); // in a + m.send(Ev::ToB); + m.send(Ev::Toggle); // in b + assert_eq!(log.entries(), ["ping", "ping"]); +} + +#[test] +fn does_not_run_while_stopped_and_stop_disposes_it() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.send(Ev::Bump); // never started + assert!(seen.is_empty()); + m.start(); + m.send(Ev::Bump); + assert_eq!(seen.entries(), [2]); + m.stop(); + m.send(Ev::Bump); + assert_eq!(seen.entries(), [2]); +} + +#[test] +fn a_run_still_pending_at_stop_is_dropped() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.start(); + let _ = m.subscribe({ + let m = m.clone(); + move || m.stop() // the write already queued the run; it must not run now + }); + m.send(Ev::Bump); + assert!(seen.is_empty()); +} + +#[test] +fn runs_after_the_whole_transition_settles() { + let seen: Log<(i32, i32)> = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Go, |t| { + t.act(|_| Ctx::patch().a(1)).act(|_| Ctx::patch().b(1)) + }) + .watch( + Ctx::A, + [Action::::run({ + let seen = seen.clone(); + move |p| seen.push((p.context().a, p.context().b)) + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + // One run, after the full action list: never mid-transition with b still 0. + assert_eq!(seen.entries(), [(1, 1)]); +} + +#[test] +fn a_watcher_writing_its_own_field_converges() { + let mut b = builder(Ctx::default()); + b.watch( + Ctx::N, + [Action::::run(|p| { + if p.context().n > 10 { + p.set_context(Ctx::patch().n(10)); + } + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::SetN(50)); + assert_eq!(m.context().n, 10); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "feedback loop")] +fn panics_in_debug_on_a_watcher_feedback_loop() { + let mut b = builder(Ctx::default()); + b.watch( + Ctx::N, + [Action::::act(|p| Ctx::patch().n(p.context().n + 1))], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::Bump); +} + +#[test] +fn watched_fields_react_independently() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::A, [Action::::run(log.action("a"))]) + .watch(Ctx::B, [Action::::run(log.action("b"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::BumpA); + m.send(Ev::BumpB); + m.send(Ev::BumpA); + assert_eq!(log.entries(), ["a", "b", "a"]); +} + +#[test] +fn a_watcher_runs_before_an_event_sent_later_by_the_same_action() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Go, |t| { + t.run(|p| { + p.set_context(Ctx::patch().n(1)); + p.send(Ev::Ping); + }) + }) + .on(EvKind::Ping, |t| t.run(log.action("ping"))) + .watch(Ctx::N, [Action::::run(log.action("watch"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + assert_eq!(log.entries(), ["watch", "ping"]); +} + +#[test] +fn a_field_written_away_and_back_in_one_action_runs_the_watcher_per_write() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Go, |t| { + t.run(|p| { + p.set_context(Ctx::patch().n(1)); + p.set_context(Ctx::patch().n(0)); + }) + }) + .watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + assert_eq!(seen.entries(), [0, 0]); // both runs come after the action +} + +#[test] +fn a_computed_watcher_runs_before_an_event_sent_later_by_the_same_action() { + let log = Log::default(); + let mut b = builder(Ctx { + items: vec![1], + ..Ctx::default() + }); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.on(EvKind::Go, |t| { + t.run(|p| { + p.set_context(Ctx::patch().items(Vec::new())); + p.send(Ev::Ping); + }) + }) + .on(EvKind::Ping, |t| t.run(log.action("ping"))) + .watch_computed(is_empty, [Action::::run(log.action("watch"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + assert_eq!(log.entries(), ["watch", "ping"]); +} + +#[test] +fn a_computed_watcher_reading_the_state_runs_on_a_transition() { + let seen: Log = Log::default(); + let mut b = builder(Ctx::default()); + let in_b = b.computed("inB", |p| p.state() == S::B); + b.watch_computed( + in_b, + [Action::::run({ + let seen = seen.clone(); + move |p| seen.push(*p.computed(in_b)) + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::ToB); + assert_eq!(seen.entries(), [true]); +} diff --git a/crates/demo-wasm/Cargo.toml b/crates/demo-wasm/Cargo.toml new file mode 100644 index 0000000..324e243 --- /dev/null +++ b/crates/demo-wasm/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "dunky-demo-wasm" +description = "The demo machines compiled to wasm (cdylib) for the JS adapter, benchmark and sandboxes." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# Every demo machine. Build a single one (for size numbers) with e.g. +# `--no-default-features --features dialog`. +default = ["palette", "cell", "pingpong", "guards", "dialog", "list", "highlight"] +palette = [] +cell = [] +pingpong = [] +guards = [] +dialog = [] +list = [] +highlight = [] + +[dependencies] +dunky-core = { workspace = true } +dunky-wasm = { workspace = true } +dunky-demo = { workspace = true } +wasm-bindgen = { workspace = true } diff --git a/crates/demo-wasm/src/lib.rs b/crates/demo-wasm/src/lib.rs new file mode 100644 index 0000000..86c93cc --- /dev/null +++ b/crates/demo-wasm/src/lib.rs @@ -0,0 +1,103 @@ +//! The demo machines as JS classes (wasm-bindgen). Built by `pnpm build:wasm` into +//! `packages/demo-wasm/pkg`. + +#![allow(unused_imports)] + +use dunky_core::{Config, Machine}; +use dunky_demo::{cell, dialog, guards, highlight, list, palette, pingpong}; +use wasm_bindgen::prelude::*; + +// One config per machine type, shared by every instance (the shape a real app has). +#[cfg(feature = "palette")] +thread_local!(static PALETTE: Config = palette::config(Vec::new())); +#[cfg(feature = "cell")] +thread_local!(static CELL: Config = cell::config()); +#[cfg(feature = "pingpong")] +thread_local!(static PINGPONG: Config = pingpong::config()); +#[cfg(feature = "dialog")] +thread_local!(static DIALOG: Config = dialog::config(0)); + +// The sandbox command palette. +#[cfg(feature = "palette")] +dunky_wasm::export_machine! { + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // Malformed commands throw: the host maps result indices onto its own array. + dunky_wasm::from_js::>(commands) + .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) + } + computed { + palette::RESULTS => Vec, + palette::ACTIVE_ID => Option, + palette::RESULT_INDICES => Vec, + } +} + +// The benchmark cell: `hit` bumps `value`, `miss` bumps `other`. +#[cfg(feature = "cell")] +dunky_wasm::export_machine! { + pub struct CellMachine(cell::Cell); + new() { CELL.with(Machine::new) } + computed {} +} + +// Ping ⇄ pong with entry/exit actions on every transition. +#[cfg(feature = "pingpong")] +dunky_wasm::export_machine! { + pub struct PingPongMachine(pingpong::PingPong); + new() { PINGPONG.with(Machine::new) } + computed {} +} + +// `k` guarded candidates, the last one wins. +#[cfg(feature = "guards")] +dunky_wasm::export_machine! { + pub struct GuardsMachine(guards::Guards); + new(k: u32) { Machine::new(&guards::config(k.max(1))) } + computed {} +} + +// A dialog whose `closing` phase lasts `exitMs`. +#[cfg(feature = "dialog")] +dunky_wasm::export_machine! { + pub struct DialogMachine(dialog::Dialog); + new(exit_ms: u32) { + DIALOG.with(|c| Machine::with_context(c, dialog::DialogCtx { exit_ms, open_count: 0 })) + } + computed {} +} + +#[cfg(feature = "list")] +thread_local!(static LIST: Config = list::config()); +#[cfg(feature = "highlight")] +thread_local!(static HIGHLIGHT: Config = highlight::config()); + +// The rendering bench's shared list (one highlighted index). +#[cfg(feature = "list")] +dunky_wasm::export_machine! { + pub struct ListMachine(list::List); + new() { LIST.with(Machine::new) } + computed {} +} + +// The rendering bench's per-row machine (one boolean). +#[cfg(feature = "highlight")] +dunky_wasm::export_machine! { + pub struct HighlightMachine(highlight::Highlight); + new(on: bool) { + HIGHLIGHT.with(|c| Machine::with_context(c, highlight::HighlightCtx { on })) + } + computed {} +} + +/// Raw boundary cost: one number in, one number out, no work. +#[wasm_bindgen] +pub fn noop(x: u32) -> u32 { + x +} + +/// The benchmark sink (proof the entry/exit/guard actions ran). +#[wasm_bindgen] +pub fn sink() -> f64 { + dunky_demo::sink() as f64 +} diff --git a/crates/demo/Cargo.toml b/crates/demo/Cargo.toml new file mode 100644 index 0000000..34bbaf2 --- /dev/null +++ b/crates/demo/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-demo" +description = "Example machines written against dunky-core (palette, cell, ping-pong, dialog). Used by the wasm demo, the benchmark and the sandboxes." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +dunky-core = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/demo/src/cell.rs b/crates/demo/src/cell.rs new file mode 100644 index 0000000..92d8d90 --- /dev/null +++ b/crates/demo/src/cell.rs @@ -0,0 +1,52 @@ +//! The benchmark "cell": one state, two counters. `hit` changes the observed field, +//! `miss` an unobserved one (mirrors `makeCoreMachine` in benchmark/competitors.ts). + +use dunky_core::{Config, Context, Event, State, Types}; +use serde::Deserialize; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum CellState { + Idle, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CellEvent { + Hit, + Miss, +} + +#[derive(Context, Clone, Debug, PartialEq, Default)] +#[context(serialize)] +pub struct CellCtx { + pub value: u32, + pub other: u32, +} + +pub struct Cell; + +impl Types for Cell { + type State = CellState; + type Event = CellEvent; + type Context = CellCtx; +} + +pub fn config() -> Config { + let mut b = Config::::builder(CellState::Idle, CellCtx::default()); + b.state(CellState::Idle, |s| { + s.on(CellEventKind::Hit, |t| { + t.run(|p| { + let value = p.context().value + 1; + p.set_context(CellCtx::patch().value(value)); + }) + }) + .on(CellEventKind::Miss, |t| { + t.run(|p| { + let other = p.context().other + 1; + p.set_context(CellCtx::patch().other(other)); + }) + }) + }); + b.build() +} diff --git a/crates/demo/src/dialog.rs b/crates/demo/src/dialog.rs new file mode 100644 index 0000000..0431418 --- /dev/null +++ b/crates/demo/src/dialog.rs @@ -0,0 +1,72 @@ +//! A dialog with an exit phase: `closing` lasts `exit_ms` (a named, context-driven delay) +//! before `closed`, so a renderer can animate out. Shows `after` timers running through +//! the host's clock. + +use dunky_core::{Config, Context, Event, State, Types}; +use serde::Deserialize; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum DialogState { + Closed, + Open, + Closing, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DialogEvent { + Open, + Close, + Toggle, +} + +#[derive(Context, Clone, Debug, PartialEq)] +#[context(serialize)] +pub struct DialogCtx { + /// How long `closing` lasts. Seeded from props. + pub exit_ms: u32, + /// How many times the dialog opened (demo data for the change mask). + pub open_count: u32, +} + +pub struct Dialog; + +impl Types for Dialog { + type State = DialogState; + type Event = DialogEvent; + type Context = DialogCtx; +} + +pub fn config(exit_ms: u32) -> Config { + use DialogEventKind as E; + use DialogState::{Closed, Closing, Open}; + let mut b = Config::::builder( + Closed, + DialogCtx { + exit_ms, + open_count: 0, + }, + ); + b.delay("exitDelay", |g| g.context().exit_ms); + b.action("countOpen", |p| { + let n = p.context().open_count + 1; + p.set_context(DialogCtx::patch().open_count(n)); + }); + b.state(Closed, |s| { + s.on(E::Open, |t| t.target(Open).action("countOpen")) + .on(E::Toggle, |t| t.target(Open).action("countOpen")) + }); + b.state(Open, |s| { + s.tag("visible") + .on(E::Close, |t| t.target(Closing)) + .on(E::Toggle, |t| t.target(Closing)) + }); + b.state(Closing, |s| { + s.tag("visible") + .after("exitDelay", |t| t.target(Closed)) + .on(E::Open, |t| t.target(Open).action("countOpen")) + .on(E::Toggle, |t| t.target(Open).action("countOpen")) + }); + b.build() +} diff --git a/crates/demo/src/guards.rs b/crates/demo/src/guards.rs new file mode 100644 index 0000000..133aabb --- /dev/null +++ b/crates/demo/src/guards.rs @@ -0,0 +1,47 @@ +//! Guard fallthrough: `k` guarded candidates for one event where only the LAST passes +//! (mirrors benchmark/tests/engine.ts, section A). + +use dunky_core::{Config, Context, Event, State, Types}; +use serde::Deserialize; + +use crate::bump; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum GuardsState { + Idle, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum GuardsEvent { + Go, +} + +#[derive(Context, Clone, Debug, PartialEq)] +#[context(serialize)] +pub struct GuardsCtx { + pub pick: u32, +} + +pub struct Guards; + +impl Types for Guards { + type State = GuardsState; + type Event = GuardsEvent; + type Context = GuardsCtx; +} + +pub fn config(k: u32) -> Config { + assert!(k > 0, "at least one candidate"); + let mut b = Config::::builder(GuardsState::Idle, GuardsCtx { pick: k - 1 }); + b.state(GuardsState::Idle, |mut s| { + for i in 0..k { + s = s.on(GuardsEventKind::Go, move |t| { + t.guard_fn(move |g| g.context().pick == i).run(|_| bump()) + }); + } + s + }); + b.build() +} diff --git a/crates/demo/src/highlight.rs b/crates/demo/src/highlight.rs new file mode 100644 index 0000000..38beb96 --- /dev/null +++ b/crates/demo/src/highlight.rs @@ -0,0 +1,44 @@ +//! The rendering bench's per-row machine: each row owns one boolean +//! (mirrors `makeCoreHighlightMachine` in benchmark/tests/rendering/bench.tsx). + +use dunky_core::{Config, Context, Event, State, Types}; +use serde::Deserialize; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum HighlightState { + Idle, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum HighlightEvent { + Set { on: bool }, +} + +#[derive(Context, Clone, Debug, PartialEq, Default)] +#[context(serialize)] +pub struct HighlightCtx { + pub on: bool, +} + +pub struct Highlight; + +impl Types for Highlight { + type State = HighlightState; + type Event = HighlightEvent; + type Context = HighlightCtx; +} + +pub fn config() -> Config { + let mut b = Config::::builder(HighlightState::Idle, HighlightCtx::default()); + b.state(HighlightState::Idle, |s| { + s.on(HighlightEventKind::Set, |t| { + t.act(|p| match p.event() { + Some(HighlightEvent::Set { on }) => HighlightCtx::patch().on(*on), + None => HighlightCtx::patch(), + }) + }) + }); + b.build() +} diff --git a/crates/demo/src/lib.rs b/crates/demo/src/lib.rs new file mode 100644 index 0000000..7b299ff --- /dev/null +++ b/crates/demo/src/lib.rs @@ -0,0 +1,30 @@ +//! Example machines written against `dunky-core`. +//! +//! - [`palette`] — the sandbox command palette, a faithful port of +//! `sandbox/shared/src/machine.ts` (computed filtering, logical navigation). +//! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). +//! - [`cell`], [`pingpong`], [`guards`] — the benchmark shapes from `benchmark/`. +//! - [`list`], [`highlight`] — the React rendering bench's shared list and per-row machine. + +use std::cell::Cell as StdCell; + +pub mod cell; +pub mod dialog; +pub mod guards; +pub mod highlight; +pub mod list; +pub mod palette; +pub mod pingpong; + +thread_local! { + static SINK: StdCell = const { StdCell::new(0) }; +} + +/// Benchmark sink: entry/exit/guard actions bump it so the work is not optimized away. +pub fn bump() { + SINK.with(|s| s.set(s.get() + 1)); +} + +pub fn sink() -> u64 { + SINK.with(StdCell::get) +} diff --git a/crates/demo/src/list.rs b/crates/demo/src/list.rs new file mode 100644 index 0000000..78833ca --- /dev/null +++ b/crates/demo/src/list.rs @@ -0,0 +1,44 @@ +//! The rendering bench's shared list: one machine holds the highlighted row +//! (mirrors `makeListMachine` in benchmark/tests/rendering/bench.tsx). + +use dunky_core::{Config, Context, Event, State, Types}; +use serde::Deserialize; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ListState { + Idle, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ListEvent { + Move { to: u32 }, +} + +#[derive(Context, Clone, Debug, PartialEq, Default)] +#[context(serialize)] +pub struct ListCtx { + pub highlighted: u32, +} + +pub struct List; + +impl Types for List { + type State = ListState; + type Event = ListEvent; + type Context = ListCtx; +} + +pub fn config() -> Config { + let mut b = Config::::builder(ListState::Idle, ListCtx::default()); + b.state(ListState::Idle, |s| { + s.on(ListEventKind::Move, |t| { + t.act(|p| match p.event() { + Some(ListEvent::Move { to }) => ListCtx::patch().highlighted(*to), + None => ListCtx::patch(), + }) + }) + }); + b.build() +} diff --git a/crates/demo/src/palette.rs b/crates/demo/src/palette.rs new file mode 100644 index 0000000..cb06d04 --- /dev/null +++ b/crates/demo/src/palette.rs @@ -0,0 +1,333 @@ +//! The command palette — a faithful port of `sandbox/shared/src/machine.ts`. +//! +//! Two states (`closed` / `open`); all the interesting logic (filter, navigate with +//! wraparound, clamp) lives in context + computed, driven by logical events. A renderer +//! never computes an index: it sends `move` / `query.set` and reads `results` / `activeId`. + +use dunky_core::{ComputedKey, Config, Context, Event, State, Types}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Command { + pub id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, +} + +/// Stamped on every execute; the nonce makes each execution a distinct value. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Executed { + pub id: String, + pub nonce: u32, +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum PaletteState { + Closed, + Open, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MoveTo { + Up, + Down, + First, + Last, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type")] +pub enum PaletteEvent { + #[serde(rename = "open")] + Open, + #[serde(rename = "close")] + Close, + #[serde(rename = "query.set")] + QuerySet { query: String }, + #[serde(rename = "move")] + Move { to: MoveTo }, + #[serde(rename = "highlight")] + Highlight { index: i64 }, + #[serde(rename = "execute")] + Execute, +} + +#[derive(Context, Clone, Debug, PartialEq)] +#[context(serialize)] +pub struct PaletteCtx { + /// Every command, in source order. Filtering is derived, never stored. + pub commands: Vec, + pub query: String, + /// Highlighted row, as an index into the FILTERED list. + pub active_index: u32, + pub last_executed: Option, +} + +pub struct Palette; + +impl Types for Palette { + type State = PaletteState; + type Event = PaletteEvent; + type Context = PaletteCtx; +} + +/// The filtered list — what every renderer reads. +pub const RESULTS: ComputedKey> = ComputedKey::new(0); +/// The highlighted command's id, clamped to the current results. +pub const ACTIVE_ID: ComputedKey> = ComputedKey::new(1); +/// The filtered list as indices into `commands` — lets a JS host keep its own command +/// objects and only receive numbers across the boundary. +pub const RESULT_INDICES: ComputedKey> = ComputedKey::new(2); + +/// Subsequence match on the lowercased label ("gpr" matches "Go to Pull Requests"). +pub fn matches(query: &str, label: &str) -> bool { + let q = query.trim().to_lowercase(); + q.is_empty() || is_subsequence(&q, &label.to_lowercase()) +} + +pub fn filter_commands(commands: &[Command], query: &str) -> Vec { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return commands.to_vec(); + } + commands + .iter() + .filter(|c| is_subsequence(&q, &c.label.to_lowercase())) + .cloned() + .collect() +} + +pub fn filter_indices(commands: &[Command], query: &str) -> Vec { + let q = query.trim().to_lowercase(); + commands + .iter() + .enumerate() + .filter(|(_, c)| q.is_empty() || is_subsequence(&q, &c.label.to_lowercase())) + .map(|(i, _)| i as u32) + .collect() +} + +fn is_subsequence(needle: &str, haystack: &str) -> bool { + let mut needle = needle.chars().peekable(); + for ch in haystack.chars() { + match needle.peek() { + Some(n) if *n == ch => { + needle.next(); + } + Some(_) => {} + None => break, + } + } + needle.peek().is_none() +} + +/// A fresh palette context over `commands` (pair with a shared config via +/// `Machine::with_context`). +pub fn context(commands: Vec) -> PaletteCtx { + PaletteCtx { + commands, + query: String::new(), + active_index: 0, + last_executed: None, + } +} + +pub fn config(commands: Vec) -> Config { + use PaletteEventKind as E; + use PaletteState::{Closed, Open}; + let mut b = Config::::builder(Closed, context(commands)); + + let results = b.computed("results", |c| { + filter_commands(c.context.commands(), c.context.query()) + }); + let active_id = b.computed("activeId", move |c| { + let results = c.computed(results); + let last = results.len().checked_sub(1)?; + let i = (*c.context.active_index() as usize).min(last); + Some(results[i].id.clone()) + }); + let indices = b.computed("resultIndices", |c| { + filter_indices(c.context.commands(), c.context.query()) + }); + assert_eq!( + (results.id(), active_id.id(), indices.id()), + (RESULTS.id(), ACTIVE_ID.id(), RESULT_INDICES.id()) + ); + + // Set the query and reset the highlight to the top of the new result set. + b.action("setQuery", |p| { + if let Some(PaletteEvent::QuerySet { query }) = p.event() { + p.set_context(PaletteCtx::patch().query(query.clone()).active_index(0u32)); + } + }); + // Move within the FILTERED list, wrapping at both ends. + b.action("move", |p| { + let Some(PaletteEvent::Move { to }) = p.event() else { + return; + }; + let count = p.computed(RESULTS).len() as u32; + if count == 0 { + return; + } + let cur = p.context().active_index; + let next = match to { + MoveTo::First => 0, + MoveTo::Last => count - 1, + MoveTo::Down => (cur + 1) % count, + MoveTo::Up => (cur + count - 1) % count, + }; + p.set_context(PaletteCtx::patch().active_index(next)); + }); + // Jump straight to a row (pointer hover / click). + b.action("highlight", |p| { + let Some(PaletteEvent::Highlight { index }) = p.event() else { + return; + }; + let count = p.computed(RESULTS).len() as i64; + if count == 0 { + return; + } + let i = (*index).clamp(0, count - 1) as u32; + p.set_context(PaletteCtx::patch().active_index(i)); + }); + // Stamp the highlighted command as executed; no-op when nothing matches. + b.action("execute", |p| { + let Some(id) = (*p.computed(ACTIVE_ID)).clone() else { + return; + }; + let nonce = p.context().last_executed.as_ref().map_or(0, |e| e.nonce) + 1; + p.set_context(PaletteCtx::patch().last_executed(Executed { id, nonce })); + }); + // Each open starts fresh. + b.action("reset", |p| { + p.set_context(PaletteCtx::patch().query("").active_index(0u32)) + }); + + b.state(Closed, |s| { + s.on(E::Open, |t| t.target(Open).action("reset")) + }); + b.state(Open, |s| { + s.on(E::Close, |t| t.target(Closed)) + .on(E::QuerySet, |t| t.action("setQuery")) + .on(E::Move, |t| t.action("move")) + .on(E::Highlight, |t| t.action("highlight")) + // Stamp the selection, then close. The connector's reaction observes the stamp. + .on(E::Execute, |t| t.target(Closed).action("execute")) + }); + b.build() +} + +/// The demo command set from `sandbox/shared/src/commands.ts`. +pub fn demo_commands() -> Vec { + let c = |id: &str, label: &str, hint: Option<&str>, group: &str| Command { + id: id.into(), + label: label.into(), + hint: hint.map(Into::into), + group: Some(group.into()), + }; + vec![ + c("home", "Go to Dashboard", Some("g h"), "Navigation"), + c("issues", "Go to Issues", Some("g i"), "Navigation"), + c("prs", "Go to Pull Requests", Some("g p"), "Navigation"), + c("settings", "Open Settings", Some(","), "Navigation"), + c("new-issue", "Create New Issue", Some("c i"), "Actions"), + c("new-pr", "Open a Pull Request", Some("c p"), "Actions"), + c("invite", "Invite Teammate", None, "Actions"), + c("theme-light", "Theme: Light", None, "Preferences"), + c("theme-dark", "Theme: Dark", None, "Preferences"), + c("theme-system", "Theme: System", None, "Preferences"), + c("logout", "Log Out", None, "Account"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use dunky_core::Machine; + + fn open_palette() -> Machine { + let m = Machine::new(&config(demo_commands())); + m.start(); + m.send(PaletteEvent::Open); + m + } + + #[test] + fn filters_by_subsequence_and_resets_highlight() { + let m = open_palette(); + m.send(PaletteEvent::Move { to: MoveTo::Down }); + assert_eq!(m.context().active_index, 1); + m.send(PaletteEvent::QuerySet { + query: "gpr".into(), + }); + assert_eq!(m.context().active_index, 0); + let results = m.computed(RESULTS); + assert_eq!( + results.iter().map(|c| c.id.as_str()).collect::>(), + ["prs"] + ); + assert_eq!(*m.computed(RESULT_INDICES), vec![2]); + } + + #[test] + fn navigation_wraps_and_execute_stamps_then_closes() { + let m = open_palette(); + m.send(PaletteEvent::Move { to: MoveTo::Up }); + assert_eq!(*m.computed(ACTIVE_ID), Some("logout".to_string())); + m.send(PaletteEvent::Execute); + assert_eq!(m.state(), PaletteState::Closed); + assert_eq!( + m.context().last_executed, + Some(Executed { + id: "logout".into(), + nonce: 1 + }) + ); + } + + #[test] + fn payload_events_rebuild_from_kind_and_fields_only() { + use dunky_core::{DeserializeEvent, EventEnum}; + let decode = |kind: PaletteEventKind, value: serde_json::Value| { + PaletteEvent::deserialize_payload(PaletteEvent::kind_index(kind), value) + }; + let set = decode( + PaletteEventKind::QuerySet, + serde_json::json!({ "type": "query.set", "query": "gpr" }), + ); + assert!(matches!(set, Ok(PaletteEvent::QuerySet { ref query }) if query == "gpr")); + let moved = decode(PaletteEventKind::Move, serde_json::json!({ "to": "last" })); + assert!(matches!(moved, Ok(PaletteEvent::Move { to: MoveTo::Last }))); + assert!(matches!( + decode(PaletteEventKind::Open, serde_json::json!({})), + Ok(PaletteEvent::Open) + )); + assert!( + decode( + PaletteEventKind::Highlight, + serde_json::json!({ "index": "x" }) + ) + .is_err() + ); + } + + #[test] + fn computed_results_are_memoized_until_an_input_changes() { + let m = open_palette(); + let a = m.computed(RESULTS); + let b = m.computed(RESULTS); + assert!(std::rc::Rc::ptr_eq(&a, &b)); + let v1 = m.computed_version(RESULTS.id()); + m.send(PaletteEvent::Move { to: MoveTo::Down }); // active_index only + assert_eq!(m.computed_version(RESULTS.id()), v1); + m.send(PaletteEvent::QuerySet { + query: "theme".into(), + }); + assert_ne!(m.computed_version(RESULTS.id()), v1); + } +} diff --git a/crates/demo/src/pingpong.rs b/crates/demo/src/pingpong.rs new file mode 100644 index 0000000..5622af4 --- /dev/null +++ b/crates/demo/src/pingpong.rs @@ -0,0 +1,44 @@ +//! Real state churn: every `go` exits one state and enters the other, running exit and +//! entry actions (mirrors benchmark/tests/engine.ts, section B). + +use dunky_core::{Config, Event, State, Types}; +use serde::Deserialize; + +use crate::bump; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum PingPongState { + Ping, + Pong, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PingPongEvent { + Go, +} + +pub struct PingPong; + +impl Types for PingPong { + type State = PingPongState; + type Event = PingPongEvent; + type Context = (); +} + +pub fn config() -> Config { + use PingPongState::{Ping, Pong}; + let mut b = Config::::builder(Ping, ()); + b.state(Ping, |s| { + s.entry_run(|_| bump()) + .exit_run(|_| bump()) + .on(PingPongEventKind::Go, |t| t.target(Pong)) + }); + b.state(Pong, |s| { + s.entry_run(|_| bump()) + .exit_run(|_| bump()) + .on(PingPongEventKind::Go, |t| t.target(Ping)) + }); + b.build() +} diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml new file mode 100644 index 0000000..3139212 --- /dev/null +++ b/crates/macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dunky-macros" +description = "Derive macros for dunky-core: State, Event, Context." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs new file mode 100644 index 0000000..08b42cf --- /dev/null +++ b/crates/macros/src/lib.rs @@ -0,0 +1,483 @@ +//! Derive macros for `dunky-core`. Use them through the re-exports in `dunky_core`. + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::{Attribute, Data, DeriveInput, Fields, Ident, LitStr, parse_macro_input}; + +/// `#[derive(State)]` on a fieldless enum: state names default to the camelCased variant +/// (`HalfOpen` → `"halfOpen"`); override with `#[state(name = "...")]`. +#[proc_macro_derive(State, attributes(state))] +pub fn derive_state(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_state(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +/// `#[derive(Event)]` on an enum: generates `Kind` (one fieldless variant per event) +/// and implements `EventEnum`. Type names default to the camelCased variant; a +/// `#[event(name = "...")]` or `#[serde(rename = "...")]` attribute overrides it. +/// Add `#[event(deserialize)]` on the enum to implement `DeserializeEvent`: the event is +/// rebuilt from its kind + payload fields, without serde's buffered tagged-enum path. +#[proc_macro_derive(Event, attributes(event))] +pub fn derive_event(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_event(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +/// `#[derive(Context)]` on a struct with named fields: generates the `Patch` builder, +/// `Reader` (tracked reads for computed values), one `Field` const per field +/// (`active_index` → `ACTIVE_INDEX`) and `::patch()`. Field names for JS are +/// camelCased; override with `#[context(name = "...")]`. Add `#[context(serialize)]` on the +/// struct to implement `SerializeFields` (every field must be `serde::Serialize`). +#[proc_macro_derive(Context, attributes(context))] +pub fn derive_context(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_context(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +fn camel(ident: &Ident) -> String { + let s = ident.to_string(); + let s = s.strip_prefix("r#").unwrap_or(&s); + if s.contains('_') { + let mut out = String::new(); + let mut upper = false; + for (i, ch) in s.chars().enumerate() { + if ch == '_' { + upper = i > 0; + continue; + } + if upper { + out.extend(ch.to_uppercase()); + upper = false; + } else { + out.push(ch); + } + } + out + } else { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_lowercase().chain(chars).collect(), + None => String::new(), + } + } +} + +/// Reads `#[(name = "...")]` (or `#[serde(rename = "...")]` when `serde_too`). +fn name_attr(attrs: &[Attribute], attr: &str, serde_too: bool) -> syn::Result> { + let mut found = None; + for a in attrs { + let is_own = a.path().is_ident(attr); + let is_serde = serde_too && a.path().is_ident("serde"); + if !is_own && !is_serde { + continue; + } + let key = if is_own { "name" } else { "rename" }; + a.parse_nested_meta(|meta| { + if meta.path.is_ident(key) { + let value: LitStr = meta.value()?.parse()?; + if is_own || found.is_none() { + found = Some(value.value()); + } + } else if meta.input.peek(syn::Token![=]) { + // Skip other `key = value` items (e.g. serde's). + let _: syn::Expr = meta.value()?.parse()?; + } else if meta.input.peek(syn::token::Paren) { + let _content; + syn::parenthesized!(_content in meta.input); + } + Ok(()) + })?; + } + Ok(found) +} + +/// serde's `rename_all` rules, applied to a (PascalCase) variant name exactly like serde does. +fn serde_case(variant: &str, rule: &str) -> Option { + let snake = || { + let mut out = String::new(); + for (i, ch) in variant.char_indices() { + if i > 0 && ch.is_uppercase() { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } + out + }; + Some(match rule { + "lowercase" => variant.to_ascii_lowercase(), + "UPPERCASE" => variant.to_ascii_uppercase(), + "PascalCase" => variant.to_owned(), + "camelCase" => { + let mut chars = variant.chars(); + chars + .next() + .map(|c| c.to_ascii_lowercase().to_string() + chars.as_str()) + .unwrap_or_default() + } + "snake_case" => snake(), + "SCREAMING_SNAKE_CASE" => snake().to_ascii_uppercase(), + "kebab-case" => snake().replace('_', "-"), + "SCREAMING-KEBAB-CASE" => snake().replace('_', "-").to_ascii_uppercase(), + _ => return None, + }) +} + +/// Reads `#[serde( = "...")]`. +fn serde_str(attrs: &[Attribute], key: &str) -> syn::Result> { + let mut found = None; + for a in attrs { + if !a.path().is_ident("serde") { + continue; + } + a.parse_nested_meta(|meta| { + if meta.path.is_ident(key) { + let value: LitStr = meta.value()?.parse()?; + found = Some(value.value()); + } else if meta.input.peek(syn::Token![=]) { + let _: syn::Expr = meta.value()?.parse()?; + } else if meta.input.peek(syn::token::Paren) { + let _content; + syn::parenthesized!(_content in meta.input); + } + Ok(()) + })?; + } + Ok(found) +} + +fn has_flag(attrs: &[Attribute], attr: &str, flag: &str) -> syn::Result { + let mut on = false; + for a in attrs { + if !a.path().is_ident(attr) { + continue; + } + a.parse_nested_meta(|meta| { + if meta.path.is_ident(flag) { + on = true; + } else if meta.input.peek(syn::Token![=]) { + let _: syn::Expr = meta.value()?.parse()?; + } + Ok(()) + })?; + } + Ok(on) +} + +fn expand_state(input: DeriveInput) -> syn::Result { + let name = &input.ident; + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "#[derive(State)] needs an enum", + )); + }; + let mut names = Vec::new(); + let mut variants = Vec::new(); + for v in &data.variants { + if !matches!(v.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + v, + "state variants cannot carry data", + )); + } + names.push(name_attr(&v.attrs, "state", false)?.unwrap_or_else(|| camel(&v.ident))); + variants.push(&v.ident); + } + let indices: Vec = (0..variants.len()).collect(); + let (impl_g, ty_g, where_g) = input.generics.split_for_impl(); + Ok(quote! { + impl #impl_g ::dunky_core::StateEnum for #name #ty_g #where_g { + const NAMES: &'static [&'static str] = &[#(#names),*]; + fn index(self) -> usize { + match self { #(#name::#variants => #indices,)* } + } + fn from_index(index: usize) -> Self { + match index { + #(#indices => #name::#variants,)* + _ => panic!("[machine] no state at index {index}"), + } + } + } + }) +} + +fn expand_event(input: DeriveInput) -> syn::Result { + let name = &input.ident; + let vis = &input.vis; + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "#[derive(Event)] needs an enum", + )); + }; + let kind = format_ident!("{}Kind", name); + let deserialize = has_flag(&input.attrs, "event", "deserialize")?; + let rename_all_fields = serde_str(&input.attrs, "rename_all_fields")?; + let rename_all = serde_str(&input.attrs, "rename_all")?; + let mut names = Vec::new(); + let mut variants = Vec::new(); + let mut patterns = Vec::new(); + let mut builders = Vec::new(); + let mut payload_arms = Vec::new(); + for (index, v) in data.variants.iter().enumerate() { + let ident = &v.ident; + payload_arms.push(match &v.fields { + Fields::Unit => quote!(#index => ::core::result::Result::Ok(#name::#ident),), + Fields::Named(fields) => { + let field_idents: Vec<&Ident> = + fields.named.iter().map(|f| f.ident.as_ref().expect("named field")).collect(); + let field_tys: Vec<&syn::Type> = fields.named.iter().map(|f| &f.ty).collect(); + let field_attrs: Vec> = fields + .named + .iter() + .map(|f| f.attrs.iter().filter(|a| a.path().is_ident("serde")).collect()) + .collect(); + // The variant's own rename_all wins over the enum's rename_all_fields. + let rename = serde_str(&v.attrs, "rename_all")? + .or_else(|| rename_all_fields.clone()) + .map(|r| quote!(#[serde(rename_all = #r)])) + .unwrap_or_default(); + quote!(#index => { + #[derive(::dunky_core::__private::serde::Deserialize)] + #[serde(crate = "::dunky_core::__private::serde")] + #rename + struct __Payload { #( #(#field_attrs)* #field_idents: #field_tys, )* } + let p = <__Payload as ::dunky_core::__private::serde::Deserialize>::deserialize(deserializer)?; + ::core::result::Result::Ok(#name::#ident { #(#field_idents: p.#field_idents,)* }) + }) + } + Fields::Unnamed(_) => quote!(#index => ::core::result::Result::Err( + <__D::Error as ::dunky_core::__private::serde::de::Error>::custom( + "tuple event variants carry no field names; use named fields" + ) + ),), + }); + } + for v in &data.variants { + let ident = &v.ident; + // Same name serde uses: explicit rename > the enum's rename_all > camelCase. + let kind_name = match name_attr(&v.attrs, "event", true)? { + Some(explicit) => explicit, + None => match &rename_all { + Some(rule) => serde_case(&ident.to_string(), rule).ok_or_else(|| { + syn::Error::new_spanned(ident, format!("unknown rename_all rule \"{rule}\"")) + })?, + None => camel(ident), + }, + }; + names.push(kind_name); + variants.push(ident.clone()); + patterns.push(match &v.fields { + Fields::Unit => quote!(#name::#ident), + Fields::Named(_) => quote!(#name::#ident { .. }), + Fields::Unnamed(_) => quote!(#name::#ident(..)), + }); + builders.push(match &v.fields { + Fields::Unit => quote!(::core::option::Option::Some(#name::#ident)), + _ => quote!(::core::option::Option::None), + }); + } + let indices: Vec = (0..variants.len()).collect(); + let deserialize_impl = if deserialize { + quote! { + impl ::dunky_core::DeserializeEvent for #name { + fn deserialize_payload<'de, __D: ::dunky_core::__private::serde::Deserializer<'de>>( + kind: usize, + deserializer: __D, + ) -> ::core::result::Result { + #[allow(unused_variables)] + let deserializer = deserializer; + match kind { + #(#payload_arms)* + _ => ::core::result::Result::Err( + <__D::Error as ::dunky_core::__private::serde::de::Error>::custom("no such event type") + ), + } + } + } + } + } else { + quote! {} + }; + Ok(quote! { + /// The event types of + #[doc = concat!("[`", stringify!(#name), "`]")] + /// without their payloads. + #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] + #vis enum #kind { #(#variants),* } + + impl ::dunky_core::EventEnum for #name { + type Kind = #kind; + const KIND_NAMES: &'static [&'static str] = &[#(#names),*]; + fn kind(&self) -> #kind { + match self { #(#patterns => #kind::#variants,)* } + } + fn kind_index(kind: #kind) -> usize { + match kind { #(#kind::#variants => #indices,)* } + } + fn kind_from_index(index: usize) -> #kind { + match index { + #(#indices => #kind::#variants,)* + _ => panic!("[machine] no event type at index {index}"), + } + } + fn from_kind(kind: #kind) -> ::core::option::Option { + match kind { #(#kind::#variants => #builders,)* } + } + } + + #deserialize_impl + }) +} + +fn expand_context(input: DeriveInput) -> syn::Result { + let name = &input.ident; + let vis = &input.vis; + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "#[derive(Context)] needs a struct", + )); + }; + let fields: Vec<_> = match &data.fields { + Fields::Named(f) => f.named.iter().collect(), + Fields::Unit => Vec::new(), + Fields::Unnamed(f) => { + return Err(syn::Error::new_spanned( + f, + "#[derive(Context)] needs named fields", + )); + } + }; + if fields.len() > 64 { + return Err(syn::Error::new_spanned( + &input.ident, + "a context supports at most 64 fields", + )); + } + let serialize = has_flag(&input.attrs, "context", "serialize")?; + let patch = format_ident!("{}Patch", name); + let reader = format_ident!("{}Reader", name); + + let idents: Vec<&Ident> = fields + .iter() + .map(|f| f.ident.as_ref().expect("named field")) + .collect(); + let tys: Vec<&syn::Type> = fields.iter().map(|f| &f.ty).collect(); + let mut js_names = Vec::new(); + for f in &fields { + let ident = f.ident.as_ref().expect("named field"); + js_names.push(name_attr(&f.attrs, "context", false)?.unwrap_or_else(|| camel(ident))); + } + let consts: Vec = idents + .iter() + .map(|i| { + let s = i.to_string(); + Ident::new( + &s.trim_start_matches("r#").to_uppercase(), + Span::call_site(), + ) + }) + .collect(); + let indices: Vec = (0..idents.len()).collect(); + let bits: Vec = indices.iter().map(|i| 1u64 << i).collect(); + + let serialize_impl = if serialize { + quote! { + impl ::dunky_core::SerializeFields for #name { + fn serialize_field<__S: ::dunky_core::__private::serde::Serializer>( + &self, + index: usize, + serializer: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + match index { + #(#indices => ::dunky_core::__private::serde::Serialize::serialize(&self.#idents, serializer),)* + _ => serializer.serialize_unit(), + } + } + } + } + } else { + quote! {} + }; + + Ok(quote! { + /// A partial update of + #[doc = concat!("[`", stringify!(#name), "`]")] + /// — set only the fields you change. + #[derive(Default)] + #[allow(dead_code)] + #vis struct #patch { + #(#idents: ::core::option::Option<#tys>,)* + } + + #[allow(dead_code)] + impl #patch { + #( + pub fn #idents(mut self, value: impl ::core::convert::Into<#tys>) -> Self { + self.#idents = ::core::option::Option::Some(value.into()); + self + } + )* + } + + /// Tracked read access for computed values. + #[derive(Clone, Copy)] + #[allow(dead_code)] + #vis struct #reader<'a> { + ctx: &'a #name, + deps: &'a ::core::cell::Cell, + } + + #[allow(dead_code)] + impl<'a> #reader<'a> { + #( + pub fn #idents(&self) -> &'a #tys { + self.deps.set(self.deps.get() | #bits); + &self.ctx.#idents + } + )* + } + + #[allow(dead_code)] + impl #name { + #(pub const #consts: ::dunky_core::Field<#name> = ::dunky_core::Field::new(#indices);)* + + /// Start a patch: `Self::patch().field(value)`. + pub fn patch() -> #patch { + ::core::default::Default::default() + } + } + + impl ::dunky_core::Context for #name { + type Patch = #patch; + type Reader<'a> = #reader<'a>; + const FIELDS: &'static [&'static str] = &[#(#js_names),*]; + #[allow(unused_mut, unused_variables)] + fn apply(&mut self, patch: #patch) -> u64 { + let mut changed = 0u64; + #( + if let ::core::option::Option::Some(value) = patch.#idents { + if self.#idents != value { + self.#idents = value; + changed |= #bits; + } + } + )* + changed + } + fn reader<'a>(&'a self, deps: &'a ::core::cell::Cell) -> #reader<'a> { + #reader { ctx: self, deps } + } + } + + #serialize_impl + }) +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..7141a07 --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-wasm" +description = "wasm-bindgen glue for dunky-core machines: export a Rust machine to JS with one macro." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +dunky-core = { workspace = true } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +serde = { workspace = true } +serde-wasm-bindgen = { workspace = true } diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..9fb406e --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,306 @@ +//! `dunky-wasm` — export a `dunky-core` machine to JavaScript. +//! +//! One macro, [`export_machine!`], turns a machine type into a `#[wasm_bindgen]` class that +//! speaks the handle protocol the TS adapter (`@dunky.dev/state-machine-wasm`) consumes: +//! +//! - **Event in**: `sendKind(kind)` for payload-less events (one number across the +//! boundary), `sendPayload(kind, { ...fields })` for the rest (only the payload fields +//! are read), `sendEvent({ type, ... })` as the generic serde path. +//! - **Changes out**: every mutating call returns a `u32` change mask (see [`STATE_BIT`], +//! [`COMMANDS_BIT`], [`FIELD_SHIFT`]), so JS re-reads only the fields that changed. +//! - **Commands out**: `takeCommands()` returns the timer commands as a flat `Uint32Array` +//! (`[op, id, ms, …]`, op 1 = start, 2 = cancel); JS runs them with `setTimeout` and +//! calls `fireTimer(id)`. +//! +//! The crate using the macro must also depend on `wasm-bindgen`. + +use dunky_core::{ + Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, Types, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use wasm_bindgen::prelude::*; + +// The protocol layout is shared with every binding (see `dunky_core::protocol`). +pub use dunky_core::protocol::{COMMANDS_BIT, FIELD_SHIFT, HIGH_FIELD, STATE_BIT}; + +#[doc(hidden)] +pub mod __private { + pub use js_sys; +} + +/// What an [`export_machine!`] constructor body may evaluate to: a machine, or a +/// `Result` whose error is thrown from the JS constructor. +pub trait IntoMachine { + fn into_machine(self) -> Result, JsError>; +} + +impl IntoMachine for Machine { + fn into_machine(self) -> Result, JsError> { + Ok(self) + } +} + +impl IntoMachine for Result, JsError> { + fn into_machine(self) -> Result, JsError> { + self + } +} + +/// Deserialize a JS value (constructor arguments, props). +pub fn from_js(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value).map_err(|e| JsError::new(&e.to_string())) +} + +/// The protocol implementation behind every exported class. +pub struct Handle { + pub machine: Machine, +} + +// `None` becomes `null`, matching the TS machines (`lastExecuted: null`, `activeId: null`). +fn serializer() -> serde_wasm_bindgen::Serializer { + serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true) +} + +/// Serialize any value for JS (plain objects, arrays, `undefined` for `None`). +pub fn to_js(value: &V) -> JsValue { + value.serialize(&serializer()).unwrap_or(JsValue::UNDEFINED) +} + +impl Handle +where + T::Event: DeserializeOwned + DeserializeEvent, + T::Context: SerializeFields, +{ + pub fn new(machine: Machine) -> Self { + Self { machine } + } + + /// Pack what changed since the last call into the change mask. + pub fn changes(&self) -> u32 { + dunky_core::protocol::take_change_mask(&self.machine) + } + + pub fn send_kind(&self, kind: u32) -> Result { + let names = ::KIND_NAMES; + if kind as usize >= names.len() { + return Err(JsError::new(&format!("[machine] no event type #{kind}"))); + } + let k = ::kind_from_index(kind as usize); + match ::from_kind(k) { + Some(event) => { + self.machine.send(event); + Ok(self.changes()) + } + None => Err(JsError::new(&format!( + "[machine] event \"{}\" carries data; use sendEvent", + names[kind as usize] + ))), + } + } + + pub fn send_event(&self, event: JsValue) -> Result { + let event: T::Event = serde_wasm_bindgen::from_value(event) + .map_err(|e| JsError::new(&format!("[machine] bad event: {e}")))?; + self.machine.send(event); + Ok(self.changes()) + } + + /// The fast path for events with a payload: the caller already knows the kind, so + /// only the payload fields are read from the object (no tagged-enum buffering). + pub fn send_payload(&self, kind: u32, event: JsValue) -> Result { + let names = ::KIND_NAMES; + if kind as usize >= names.len() { + return Err(JsError::new(&format!("[machine] no event type #{kind}"))); + } + let deserializer = serde_wasm_bindgen::Deserializer::from(event); + let event = + ::deserialize_payload(kind as usize, deserializer) + .map_err(|e| { + JsError::new(&format!( + "[machine] bad \"{}\" event: {e}", + names[kind as usize] + )) + })?; + self.machine.send(event); + Ok(self.changes()) + } + + pub fn state_index(&self) -> u32 { + self.machine.state().index() as u32 + } + + pub fn field(&self, index: u32) -> JsValue { + let ctx = self.machine.context(); + ctx.serialize_field(index as usize, &serializer()) + .unwrap_or(JsValue::UNDEFINED) + } + + /// Every field, in order (the initial mirror). + pub fn fields(&self) -> js_sys::Array { + let ctx = self.machine.context(); + let out = js_sys::Array::new(); + for i in 0..::FIELDS.len() { + out.push( + &ctx.serialize_field(i, &serializer()) + .unwrap_or(JsValue::UNDEFINED), + ); + } + out + } + + pub fn computed_version(&self, index: u32) -> f64 { + self.machine.computed_version(index as usize) as f64 + } + + pub fn start(&self) -> u32 { + self.machine.start(); + self.changes() + } + + pub fn stop(&self) -> u32 { + self.machine.stop(); + self.changes() + } + + pub fn fire_timer(&self, id: u32) -> u32 { + self.machine.fire_timer(id); + self.changes() + } + + pub fn take_commands(&self) -> Vec { + dunky_core::protocol::encode_commands(&self.machine.take_commands()) + } + + /// Static description for the adapter: names, which events are payload-less, tags. + pub fn meta(&self) -> JsValue { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Meta<'a> { + states: &'a [&'a str], + events: &'a [&'a str], + unit_events: Vec, + fields: &'a [&'a str], + computed: Vec<&'a str>, + tags: Vec<&'a [&'a str]>, + field_shift: u32, + high_field: usize, + } + let config = self.machine.config(); + let events = ::KIND_NAMES; + let states = ::NAMES; + to_js(&Meta { + states, + events, + unit_events: (0..events.len()) + .map(|i| { + let k = ::kind_from_index(i); + ::from_kind(k).is_some() + }) + .collect(), + fields: ::FIELDS, + computed: config.computed_names().collect(), + tags: (0..states.len()) + .map(|i| config.tags(::from_index(i))) + .collect(), + field_shift: FIELD_SHIFT, + high_field: HIGH_FIELD, + }) + } +} + +/// Export a machine type as a JS class. +/// +/// ```ignore +/// dunky_wasm::export_machine! { +/// /// The command palette. +/// pub struct PaletteMachine(dunky_demo::palette::Palette); +/// new(commands: JsValue) { +/// // A `Result` body throws its error from the JS constructor. +/// dunky_wasm::from_js(commands).map(|c| Machine::new(&dunky_demo::palette::config(c))) +/// } +/// computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +/// } +/// ``` +#[macro_export] +macro_rules! export_machine { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident($types:ty); + new($($arg:ident : $argty:ty),* $(,)?) $make:block + computed { $($key:expr => $cty:ty),* $(,)? } + ) => { + $(#[$meta])* + #[::wasm_bindgen::prelude::wasm_bindgen] + $vis struct $name { + handle: $crate::Handle<$types>, + } + + #[::wasm_bindgen::prelude::wasm_bindgen] + impl $name { + /// Throws when the constructor body returns an error (bad arguments). + #[wasm_bindgen(constructor)] + #[allow(unused_braces)] // `$make` is the caller's block, passed as an argument + pub fn new($($arg: $argty),*) -> ::core::result::Result<$name, ::wasm_bindgen::JsError> { + let machine = $crate::IntoMachine::<$types>::into_machine($make)?; + ::core::result::Result::Ok($name { handle: $crate::Handle::new(machine) }) + } + /// Payload-less event by kind index. Returns the change mask. + #[wasm_bindgen(js_name = sendKind)] + pub fn send_kind(&self, kind: u32) -> ::core::result::Result { + self.handle.send_kind(kind) + } + /// Any event object `{ type, ...payload }`. Returns the change mask. + #[wasm_bindgen(js_name = sendEvent)] + pub fn send_event(&self, event: ::wasm_bindgen::JsValue) -> ::core::result::Result { + self.handle.send_event(event) + } + /// An event with a payload, by kind index. Returns the change mask. + #[wasm_bindgen(js_name = sendPayload)] + pub fn send_payload(&self, kind: u32, event: ::wasm_bindgen::JsValue) -> ::core::result::Result { + self.handle.send_payload(kind, event) + } + #[wasm_bindgen(js_name = stateIndex)] + pub fn state_index(&self) -> u32 { + self.handle.state_index() + } + pub fn field(&self, index: u32) -> ::wasm_bindgen::JsValue { + self.handle.field(index) + } + pub fn fields(&self) -> $crate::__private::js_sys::Array { + self.handle.fields() + } + #[allow(unused_variables)] + pub fn computed(&self, index: u32) -> ::wasm_bindgen::JsValue { + $( + if index as usize == ($key).id() { + let value: ::std::rc::Rc<$cty> = self.handle.machine.computed($key); + return $crate::to_js(&*value); + } + )* + ::wasm_bindgen::JsValue::UNDEFINED + } + #[wasm_bindgen(js_name = computedVersion)] + pub fn computed_version(&self, index: u32) -> f64 { + self.handle.computed_version(index) + } + pub fn start(&self) -> u32 { + self.handle.start() + } + pub fn stop(&self) -> u32 { + self.handle.stop() + } + #[wasm_bindgen(js_name = fireTimer)] + pub fn fire_timer(&self, id: u32) -> u32 { + self.handle.fire_timer(id) + } + #[wasm_bindgen(js_name = takeCommands)] + pub fn take_commands(&self) -> ::std::vec::Vec { + self.handle.take_commands() + } + pub fn meta(&self) -> ::wasm_bindgen::JsValue { + self.handle.meta() + } + } + }; +} diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..00c6813 --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Build the demo machines to wasm for the JS adapter, benchmark and sandboxes. +# Needs: rustup target wasm32-unknown-unknown, wasm-bindgen-cli matching the crate +# version (see Cargo.toml), and optionally wasm-opt (binaryen) for the size pass. +set -euo pipefail +cd "$(dirname "$0")/.." + +OUT=packages/demo-wasm/pkg +WASM=target/wasm32-unknown-unknown/wasm/dunky_demo_wasm.wasm + +cargo build -p dunky-demo-wasm --target wasm32-unknown-unknown --profile wasm +# --target web: one ES module that works in browsers (await init()) and Node (initSync). +# --weak-refs: instances are freed by a FinalizationRegistry, so JS never calls free(). +wasm-bindgen --target web --weak-refs --out-dir "$OUT" "$WASM" + +if command -v wasm-opt >/dev/null 2>&1; then + # The features rustc enables by default for wasm32 (Rust >= 1.82); binaryen must accept them. + wasm-opt -Oz --strip-debug --strip-producers \ + --enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int \ + --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue \ + "$OUT/dunky_demo_wasm_bg.wasm" -o "$OUT/dunky_demo_wasm_bg.wasm" +else + echo "wasm-opt not found: skipping the size pass (brew install binaryen)" >&2 +fi + +raw=$(wc -c < "$OUT/dunky_demo_wasm_bg.wasm" | tr -d ' ') +gz=$(gzip -9c "$OUT/dunky_demo_wasm_bg.wasm" | wc -c | tr -d ' ') +glue=$(gzip -9c "$OUT/dunky_demo_wasm.js" | wc -c | tr -d ' ') +echo "wasm: ${raw} B raw, ${gz} B gzip · JS glue: ${glue} B gzip" From 7fb379ae9eae6bdd2705e7615a01f7ee07bd7e2b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:11:07 +0200 Subject: [PATCH 04/28] feat(wasm): TS adapter for Rust machines and the demo package - @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 --- .changeset/wasm-adapter-package.md | 32 +++ .oxlintrc.json | 2 +- packages/demo-wasm/package.json | 20 ++ packages/demo-wasm/src/index.ts | 109 +++++++++ packages/demo-wasm/src/node.ts | 9 + packages/demo-wasm/tests/parity.test.ts | 131 +++++++++++ packages/demo-wasm/tsconfig.json | 5 + packages/wasm/LICENSE | 21 ++ packages/wasm/README.md | 54 +++++ packages/wasm/package.json | 39 ++++ packages/wasm/src/index.ts | 285 ++++++++++++++++++++++++ packages/wasm/tests/adapter.test.ts | 190 ++++++++++++++++ packages/wasm/tsconfig.json | 5 + 13 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 .changeset/wasm-adapter-package.md create mode 100644 packages/demo-wasm/package.json create mode 100644 packages/demo-wasm/src/index.ts create mode 100644 packages/demo-wasm/src/node.ts create mode 100644 packages/demo-wasm/tests/parity.test.ts create mode 100644 packages/demo-wasm/tsconfig.json create mode 100644 packages/wasm/LICENSE create mode 100644 packages/wasm/README.md create mode 100644 packages/wasm/package.json create mode 100644 packages/wasm/src/index.ts create mode 100644 packages/wasm/tests/adapter.test.ts create mode 100644 packages/wasm/tsconfig.json diff --git a/.changeset/wasm-adapter-package.md b/.changeset/wasm-adapter-package.md new file mode 100644 index 0000000..a3bcc09 --- /dev/null +++ b/.changeset/wasm-adapter-package.md @@ -0,0 +1,32 @@ +--- +'@dunky.dev/state-machine-wasm': minor +--- + +Add `@dunky.dev/state-machine-wasm` — run a machine written in Rust (on the +`dunky-core` crate) and compiled to wasm behind the same `Machine` interface +as the TS engine. Export the machine type from Rust with +`dunky_wasm::export_machine!`, build it with `wasm-bindgen`, and wrap an +instance with `fromWasm`: the connector, `useMachine` and every target (React, +Solid, React Native, OpenTUI) consume it unchanged. + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/machines.js' // wasm-bindgen output + +await init() +const palette = fromWasm(new PaletteMachine(commands)) +palette.start() +palette.send({ type: 'open' }) +``` + +Reads stay plain property reads. `context` is a JS mirror with a stable +identity, updated in place: every call into Rust returns a change mask, so +only the fields that changed cross the boundary. Computed values are cached +until Rust reports a new version, and the `computed` option maps a raw value +once per change — for example, indices that crossed the boundary back onto +your own objects. Timers run on the host: the machine asks for them, and the +adapter schedules them with `setTimeout`, or with the `scheduler` you pass. + +One deliberate difference from the TS engine: subscribers are notified once +per call (`send`, `start`, `stop`, a timer firing), after the whole +run-to-completion step, instead of once per change inside it. diff --git a/.oxlintrc.json b/.oxlintrc.json index f1dd1ef..9e7d62e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,5 +45,5 @@ } }, "globals": {}, - "ignorePatterns": ["**/dist/**", "website/src/snippets/**"] + "ignorePatterns": ["**/dist/**", "website/src/snippets/**", "packages/demo-wasm/pkg/**"] } diff --git a/packages/demo-wasm/package.json b/packages/demo-wasm/package.json new file mode 100644 index 0000000..b2cf5e0 --- /dev/null +++ b/packages/demo-wasm/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dunky.dev/demo-wasm", + "version": "0.0.0", + "private": true, + "description": "The demo machines compiled from Rust (crates/demo) to wasm, wrapped as TS Machines. Build the wasm first: pnpm build:wasm.", + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./node": "./src/node.ts", + "./pkg/*": "./pkg/*" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:*", + "@dunky.dev/state-machine-wasm": "workspace:*", + "@sandbox/cmdk-core": "workspace:*" + } +} diff --git a/packages/demo-wasm/src/index.ts b/packages/demo-wasm/src/index.ts new file mode 100644 index 0000000..bac6dfe --- /dev/null +++ b/packages/demo-wasm/src/index.ts @@ -0,0 +1,109 @@ +/** + * The Rust demo machines (crates/demo) behind the TS `Machine` interface. + * + * Load the wasm once — `await loadDemo()` in a browser, `loadDemoSync(bytes)` (or + * `./node`) in Node — then build machines with the factories below. + */ +import type { Machine } from '@dunky.dev/state-machine' +import { fromWasm, type WasmMachineHandle } from '@dunky.dev/state-machine-wasm' +import type { + Command, + CommandPaletteComputed, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteState, +} from '@sandbox/cmdk-core' +import init, { + CellMachine, + DialogMachine, + GuardsMachine, + HighlightMachine, + initSync, + ListMachine, + noop, + PaletteMachine, + PingPongMachine, + sink, +} from '../pkg/dunky_demo_wasm.js' + +export { noop, sink } + +/** Load the wasm in a browser (fetches `dunky_demo_wasm_bg.wasm` next to the glue). */ +export async function loadDemo(input?: Parameters[0]): Promise { + await init(input) +} + +/** Load the wasm synchronously from its bytes (Node, tests, workers). */ +export function loadDemoSync(bytes: BufferSource): void { + initSync({ module: bytes }) +} + +export type PaletteMachineTS = Machine< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed +> + +/** The command palette. `results` crosses the boundary as indices and maps back onto + * the caller's own command objects (Rust owns ids, rich values stay in JS). */ +export function createPalette(commands: Command[]): PaletteMachineTS { + const handle = new PaletteMachine(commands) as unknown as WasmMachineHandle + return fromWasm< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed + >(handle, { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as ArrayLike, i => commands[i]!), + }, + }, + }) +} + +/** The same palette, but `results` crosses the boundary as full objects (for comparison). */ +export function createPaletteObjects(commands: Command[]): PaletteMachineTS { + return fromWasm(new PaletteMachine(commands) as unknown as WasmMachineHandle) +} + +export type CellContext = { value: number; other: number } +export function createCell(): Machine<'idle', CellContext, { type: 'hit' | 'miss' }> { + return fromWasm(new CellMachine() as unknown as WasmMachineHandle) +} + +export function createPingPong(): Machine<'ping' | 'pong', object, { type: 'go' }> { + return fromWasm(new PingPongMachine() as unknown as WasmMachineHandle) +} + +export function createGuards(k: number): Machine<'idle', { pick: number }, { type: 'go' }> { + return fromWasm(new GuardsMachine(k) as unknown as WasmMachineHandle) +} + +export type DialogState = 'closed' | 'open' | 'closing' +export type DialogContext = { exitMs: number; openCount: number } +export type DialogEvent = { type: 'open' | 'close' | 'toggle' } +export function createDialog(exitMs: number): Machine { + return fromWasm(new DialogMachine(exitMs) as unknown as WasmMachineHandle) +} + +/** The rendering bench's shared list: one highlighted index. */ +export function createList(): Machine< + 'idle', + { highlighted: number }, + { type: 'move'; to: number } +> { + return fromWasm(new ListMachine() as unknown as WasmMachineHandle) +} + +/** The rendering bench's per-row machine: one boolean. */ +export function createHighlight( + on: boolean, +): Machine<'idle', { on: boolean }, { type: 'set'; on: boolean }> { + return fromWasm(new HighlightMachine(on) as unknown as WasmMachineHandle) +} + +/** Raw handles, for measuring the boundary without the adapter. */ +export const raw = { PaletteMachine, CellMachine, PingPongMachine, GuardsMachine, DialogMachine } diff --git a/packages/demo-wasm/src/node.ts b/packages/demo-wasm/src/node.ts new file mode 100644 index 0000000..1081d75 --- /dev/null +++ b/packages/demo-wasm/src/node.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs' +import { loadDemoSync } from './index' + +/** Load the demo wasm in Node from the built package. */ +export function loadDemoNode(): void { + loadDemoSync(readFileSync(new URL('../pkg/dunky_demo_wasm_bg.wasm', import.meta.url))) +} + +export * from './index' diff --git a/packages/demo-wasm/tests/parity.test.ts b/packages/demo-wasm/tests/parity.test.ts new file mode 100644 index 0000000..cf1f3d4 --- /dev/null +++ b/packages/demo-wasm/tests/parity.test.ts @@ -0,0 +1,131 @@ +/** + * Parity: the Rust palette (through wasm + the adapter) must behave exactly like the TS + * palette in sandbox/shared for the same event sequence — state, context and computed. + * Needs the wasm build: `pnpm build:wasm`. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { machine } from '@dunky.dev/state-machine' +import { + commandPaletteMachineConfig, + DEMO_COMMANDS, + type CommandPaletteEvent, +} from '@sandbox/cmdk-core' +import { createDialog, createPalette, createPaletteObjects, loadDemoNode } from '../src/node' + +beforeAll(() => loadDemoNode()) + +const script: CommandPaletteEvent[] = [ + { type: 'open' }, + { type: 'move', to: 'down' }, + { type: 'move', to: 'down' }, + { type: 'query.set', query: 'go' }, + { type: 'move', to: 'up' }, + { type: 'highlight', index: 99 }, + { type: 'query.set', query: 'zzz' }, + { type: 'move', to: 'down' }, + { type: 'execute' }, + { type: 'query.set', query: 'theme' }, + { type: 'move', to: 'last' }, + { type: 'execute' }, + { type: 'open' }, + { type: 'move', to: 'first' }, + { type: 'execute' }, + { type: 'close' }, +] + +const snapshot = (m: ReturnType) => ({ + state: m.state, + query: m.context.query, + activeIndex: m.context.activeIndex, + lastExecuted: m.context.lastExecuted, + results: m.computed.results.map(c => c.id), + activeId: m.computed.activeId, +}) + +describe('wasm palette', () => { + it('matches the TS palette step by step', () => { + const ts = machine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) + const rs = createPalette(DEMO_COMMANDS) + ts.start() + rs.start() + for (const event of script) { + ts.send(event) + rs.send(event) + expect(snapshot(rs)).toEqual(snapshot(ts)) + } + }) + + it('maps result indices back onto the caller’s own command objects', () => { + const rs = createPalette(DEMO_COMMANDS) + rs.start() + rs.send({ type: 'open' }) + expect(rs.computed.results[0]).toBe(DEMO_COMMANDS[0]) + expect(createPaletteObjects(DEMO_COMMANDS).computed.results).toEqual(DEMO_COMMANDS) + }) + + it('keeps the context mirror identity and notifies once per send', () => { + const rs = createPalette(DEMO_COMMANDS) + const ctx = rs.context + const fn = vi.fn() + rs.subscribe(fn) + rs.send({ type: 'open' }) + rs.send({ type: 'query.set', query: 'issue' }) + expect(rs.context).toBe(ctx) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('a selection fires only when its value changes', () => { + const rs = createPalette(DEMO_COMMANDS) + const seen: Array = [] + rs.select.computed('activeId').subscribe(id => seen.push(id)) + rs.send({ type: 'open' }) + rs.send({ type: 'move', to: 'down' }) + rs.send({ type: 'move', to: 'down' }) + rs.send({ type: 'query.set', query: '' }) // query unchanged, index reset + expect(seen).toEqual(['issues', 'prs', 'home']) + }) +}) + +describe('wasm dialog', () => { + it('runs the `after` timer through the host clock', () => { + vi.useFakeTimers() + try { + const d = createDialog(150) + d.start() + d.send({ type: 'open' }) + expect(d.state).toBe('open') + expect(d.hasTag('visible')).toBe(true) + d.send({ type: 'close' }) + expect(d.state).toBe('closing') + vi.advanceTimersByTime(149) + expect(d.state).toBe('closing') + vi.advanceTimersByTime(1) + expect(d.state).toBe('closed') + expect(d.context.openCount).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the pending timer when the state is left early', () => { + vi.useFakeTimers() + try { + const d = createDialog(150) + d.start() + d.send({ type: 'open' }) + d.send({ type: 'close' }) + d.send({ type: 'open' }) // back to open before the exit delay ends + vi.advanceTimersByTime(500) + expect(d.state).toBe('open') + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('wasm constructor', () => { + it('throws on malformed props instead of guessing', () => { + expect(() => createPalette([{ id: 1 } as unknown as (typeof DEMO_COMMANDS)[number]])).toThrow() + }) +}) diff --git a/packages/demo-wasm/tsconfig.json b/packages/demo-wasm/tsconfig.json new file mode 100644 index 0000000..3fd6989 --- /dev/null +++ b/packages/demo-wasm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "tests"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/packages/wasm/LICENSE b/packages/wasm/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/wasm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan Banov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/wasm/README.md b/packages/wasm/README.md new file mode 100644 index 0000000..882d099 --- /dev/null +++ b/packages/wasm/README.md @@ -0,0 +1,54 @@ +# @dunky.dev/state-machine-wasm + +Run a Rust machine (written with [`dunky-core`](../../crates/core)) behind the TypeScript +`Machine` interface. Every target — react, solid, native, opentui — and the connector +consume it unchanged. + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import { useMachine } from '@dunky.dev/react-state-machine' +import init, { PaletteMachine } from './pkg/my_machines.js' // built with wasm-bindgen + +await init() + +function CommandPalette(props) { + const { api } = useMachine( + p => fromWasm(new PaletteMachine(p.commands)), + connectCommandPalette, + effects, + props, + ) + // … +} +``` + +On the Rust side, export the machine with one macro: + +```rust +dunky_wasm::export_machine! { + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands) + .map(|commands| Machine::with_context(&CONFIG.with(Clone::clone), palette::context(commands))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +} +``` + +## How it works + +- **Events in.** Payload-less events cross as one number (`sendKind`); events with a + payload cross as their fields only (`sendPayload`, when the Rust event derives + `#[event(deserialize)]`). +- **Changes out.** Every call returns a change mask. The adapter keeps a plain JS mirror of + the context (one object, identity stable) and re-reads only the fields the mask names, + so reading context is a property read. +- **Computed values** are cached by the version the Rust side reports. A `computed` + mapping can read another Rust value and remap it — e.g. read indices and map them onto + the host's own objects, so rich values never cross the boundary. +- **Timers** are the host's job: the machine emits start/cancel commands and the adapter + runs them with `setTimeout` (or the `scheduler` you pass). + +One deliberate difference from the TS engine: observers are notified once per call +(send, start, stop, timer), after the whole run-to-completion step. diff --git a/packages/wasm/package.json b/packages/wasm/package.json new file mode 100644 index 0000000..4119a32 --- /dev/null +++ b/packages/wasm/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dunky.dev/state-machine-wasm", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/wasm" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:*" + } +} diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts new file mode 100644 index 0000000..3d6e925 --- /dev/null +++ b/packages/wasm/src/index.ts @@ -0,0 +1,285 @@ +/** + * `@dunky.dev/state-machine-wasm` — run a Rust (`dunky-core`) machine behind the + * TypeScript `Machine` interface, so every target (react, solid, native, opentui) and + * the connector consume it unchanged. + * + * The Rust side is exported with `dunky_wasm::export_machine!`. Each call returns a + * change mask; this adapter keeps a plain JS mirror of the context and re-reads only + * the fields the mask names, so reads stay property reads. Timers are the host's job: + * the machine emits start/cancel commands and the adapter runs them with `setTimeout`. + * + * One deliberate difference from the TS engine: observers are notified ONCE per call + * (send, start, stop, timer), after the whole run-to-completion step, instead of once + * per change inside it. + */ +import { + makeBroadcast, + makeSelection, + type Machine, + type Select, + type Selection, +} from '@dunky.dev/state-machine' + +/** The protocol every class exported by `dunky_wasm::export_machine!` implements. */ +export interface WasmMachineHandle { + sendKind: (kind: number) => number + sendEvent: (event: unknown) => number + /** Fast path for events with a payload (present when the Rust event derives `deserialize`). */ + sendPayload?: (kind: number, event: unknown) => number + stateIndex: () => number + field: (index: number) => unknown + fields: () => unknown[] + computed: (index: number) => unknown + computedVersion: (index: number) => number + start: () => number + stop: () => number + fireTimer: (id: number) => number + takeCommands: () => Uint32Array + meta: () => WasmMachineMeta +} + +/** The static description a handle reports about its machine type. */ +export interface WasmMachineMeta { + states: string[] + events: string[] + /** Per event type: true when the event carries no payload (sent as one number). */ + unitEvents: boolean[] + fields: string[] + computed: string[] + /** Per state: its tags. */ + tags: string[][] + fieldShift: number + highField: number +} + +/** The host clock. Defaults to the global `setTimeout` / `clearTimeout`. */ +export interface Scheduler { + setTimeout: (fn: () => void, ms: number) => unknown + clearTimeout: (handle: unknown) => void +} + +/** How to produce one computed value on the JS side. */ +export type ComputedMapping = + | ((raw: unknown) => Value) + | { + /** Read this Rust computed value instead (e.g. indices instead of objects). */ + from: string + map: (raw: unknown) => Value + } + +export interface FromWasmOptions { + scheduler?: Scheduler + /** + * Map a raw computed value before it is cached — e.g. read indices that crossed the + * boundary and turn them back into the host's own objects. Runs only when the value + * changed. + */ + computed?: { [K in keyof Computed]?: ComputedMapping } +} + +const STATE_BIT = 1 +const COMMANDS_BIT = 2 +const OP_START = 1 + +interface Prepared { + meta: WasmMachineMeta + eventIndex: Map + computedIndex: Map +} + +// Meta is per machine TYPE: read it once per exported class, not per instance. +const prepared = new WeakMap() + +function prepare(handle: WasmMachineHandle): Prepared { + const type = Object.getPrototypeOf(handle) as object + let p = prepared.get(type) + if (!p) { + const meta = handle.meta() + p = { + meta, + eventIndex: new Map(meta.events.map((name, i) => [name, i])), + computedIndex: new Map(meta.computed.map((name, i) => [name, i])), + } + prepared.set(type, p) + } + return p +} + +const defaultScheduler: Scheduler = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: handle => clearTimeout(handle as ReturnType), +} + +/** Wrap a wasm machine handle in the `Machine` interface. */ +export function fromWasm< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + handle: WasmMachineHandle, + options: FromWasmOptions = {}, +): Machine { + const { meta, eventIndex, computedIndex } = prepare(handle) + const scheduler = options.scheduler ?? defaultScheduler + const mappings = (options.computed ?? {}) as Record | undefined> + const { states, fields, unitEvents, fieldShift, highField } = meta + + // The mirror: one plain object, identity stable, mutated in place like the TS engine's. + const context = {} as Record + const initial = handle.fields() + for (let i = 0; i < fields.length; i++) context[fields[i]!] = initial[i] + let stateIdx = handle.stateIndex() + let stateValue = states[stateIdx] as State + let running = false + + // Computed values are cached with the Rust version stamp; `epoch` skips even the + // version check while nothing changed since the last read. + let epoch = 0 + const cachedValue: unknown[] = [] + const cachedVersion: number[] = [] + const cachedEpoch: number[] = [] + const computed = {} as Record + for (const [name, own] of computedIndex) { + const mapping = mappings[name] + const map = typeof mapping === 'function' ? mapping : mapping?.map + const source = typeof mapping === 'object' ? computedIndex.get(mapping.from) : own + if (source === undefined) + throw new Error(`[machine] no computed "${(mapping as { from: string }).from}"`) + const i = own + cachedEpoch[i] = -1 + cachedVersion[i] = -1 + Object.defineProperty(computed, name, { + enumerable: true, + get() { + if (cachedEpoch[i] === epoch) return cachedValue[i] + cachedEpoch[i] = epoch + const version = handle.computedVersion(source) + if (version !== cachedVersion[i]) { + cachedVersion[i] = version + const raw = handle.computed(source) + cachedValue[i] = map ? map(raw) : raw + } + return cachedValue[i] + }, + }) + } + + const broadcast = makeBroadcast() + const startListeners = new Set<() => void>() + const stopListeners = new Set<() => void>() + const timers = new Map() + + function runCommands(): void { + const commands = handle.takeCommands() + for (let i = 0; i < commands.length; i += 3) { + const id = commands[i + 1]! + if (commands[i] === OP_START) { + timers.set( + id, + scheduler.setTimeout( + () => { + timers.delete(id) + apply(handle.fireTimer(id)) + }, + commands[i + 2]!, + ), + ) + } else { + const t = timers.get(id) + if (t !== undefined) scheduler.clearTimeout(t) + timers.delete(id) + } + } + } + + function apply(mask: number): void { + if (mask === 0) return + if (mask & STATE_BIT) { + stateIdx = handle.stateIndex() + stateValue = states[stateIdx] as State + } + let bits = mask >>> fieldShift + let i = 0 + while (bits !== 0) { + if (bits & 1) { + if (i < highField) { + context[fields[i]!] = handle.field(i) + } else { + // The top bit stands for every field from `highField` up. + for (let j = highField; j < fields.length; j++) context[fields[j]!] = handle.field(j) + } + } + bits >>>= 1 + i++ + } + if (mask & COMMANDS_BIT) runCommands() + if (mask & ~COMMANDS_BIT) { + epoch++ + broadcast.notify() + } + } + + const sendPayload = handle.sendPayload?.bind(handle) + const send = (event: Event): void => { + const kind = eventIndex.get(event.type) + if (kind === undefined) return // no such event type: ignored, like an unhandled event + apply( + unitEvents[kind] + ? handle.sendKind(kind) + : sendPayload + ? sendPayload(kind, event) + : handle.sendEvent(event), + ) + } + + const tagSets = meta.tags.map(t => new Set(t)) + const selection = (selector: () => Value): Selection => + makeSelection(selector, onWake => broadcast.add(onWake)) + const select = ((selector: () => Value) => selection(selector)) as Select< + State, + Context, + Computed + > + select.context = key => selection(() => (context as Context)[key]) + select.computed = key => selection(() => (computed as Computed)[key]) + select.state = () => selection(() => stateValue) + + return { + get state() { + return stateValue + }, + get context() { + return context as Context + }, + get computed() { + return computed as Computed + }, + hasTag: tag => tagSets[stateIdx]?.has(tag) ?? false, + matches: name => stateValue === name, + send, + subscribe: listener => broadcast.add(listener), + select, + start() { + if (running) return + running = true + apply(handle.start()) + for (const fn of startListeners) fn() + }, + stop() { + if (!running) return + running = false + apply(handle.stop()) + for (const fn of stopListeners) fn() + }, + onStart(fn) { + startListeners.add(fn) + if (running) fn() + return () => startListeners.delete(fn) + }, + onStop(fn) { + stopListeners.add(fn) + return () => stopListeners.delete(fn) + }, + } +} diff --git a/packages/wasm/tests/adapter.test.ts b/packages/wasm/tests/adapter.test.ts new file mode 100644 index 0000000..0cc50b8 --- /dev/null +++ b/packages/wasm/tests/adapter.test.ts @@ -0,0 +1,190 @@ +/** + * `fromWasm` against a scripted fake handle — the protocol edges the real machines + * rarely hit: the shared high-field bit, timer commands, computed caching and + * remapping, unknown events, lifecycle listeners. (The real wasm machines are covered + * by the parity tests in packages/demo-wasm.) + */ +import { describe, expect, it, vi } from 'vitest' +import { fromWasm, type Scheduler, type WasmMachineHandle, type WasmMachineMeta } from '../src' + +const FIELD = (i: number) => 1 << (2 + i) +const STATE = 1 +const COMMANDS = 2 + +function fake(meta: Partial = {}) { + const fields = meta.fields ?? ['a', 'b'] + const state = { + index: 0, + values: fields.map(() => 0) as unknown[], + version: 1, + commands: [] as number[], + } + const calls = { field: 0, computed: 0, version: 0 } + let onSend: (kind: number) => number = () => 0 + class Handle implements WasmMachineHandle { + sendKind = vi.fn((kind: number) => onSend(kind)) + sendEvent = vi.fn((_event: unknown) => onSend(-1)) + stateIndex = () => state.index + field = (i: number) => { + calls.field++ + return state.values[i] + } + fields = () => [...state.values] + computed = (i: number) => { + calls.computed++ + return i === 0 ? [2, 0] : 'other' + } + computedVersion = () => { + calls.version++ + return state.version + } + start = () => 0 + stop = () => 0 + fireTimer = vi.fn((_id: number) => 0) + takeCommands = () => { + const out = new Uint32Array(state.commands) + state.commands = [] + return out + } + meta = (): WasmMachineMeta => ({ + states: ['idle', 'busy'], + events: ['go', 'set'], + unitEvents: [true, false], + fields, + computed: ['indices', 'other'], + tags: [[], ['working']], + fieldShift: 2, + highField: 29, + ...meta, + }) + } + const handle = new Handle() + return { handle, state, calls, respond: (fn: (kind: number) => number) => (onSend = fn) } +} + +function manualScheduler() { + const timers = new Map void; ms: number }>() + let next = 0 + const scheduler: Scheduler = { + setTimeout: (fn, ms) => { + timers.set(++next, { fn, ms }) + return next + }, + clearTimeout: handle => timers.delete(handle as number), + } + return { scheduler, timers } +} + +describe('fromWasm', () => { + it('sends payload-less events by kind and the rest as objects; ignores unknown types', () => { + const { handle } = fake() + const m = fromWasm<'idle' | 'busy', { a: number; b: number }, { type: string; v?: number }>( + handle, + ) + m.send({ type: 'go' }) + m.send({ type: 'set', v: 1 }) + m.send({ type: 'nope' }) + expect(handle.sendKind).toHaveBeenCalledWith(0) + expect(handle.sendEvent).toHaveBeenCalledWith({ type: 'set', v: 1 }) + expect(handle.sendKind).toHaveBeenCalledTimes(1) + expect(handle.sendEvent).toHaveBeenCalledTimes(1) + }) + + it('re-reads only the fields the mask names, then notifies once', () => { + const { handle, state, calls, respond } = fake() + const m = fromWasm<'idle' | 'busy', { a: number; b: number }, { type: 'go' }>(handle) + const listener = vi.fn() + m.subscribe(listener) + respond(() => { + state.index = 1 + state.values = [0, 7] + return STATE | FIELD(1) + }) + m.send({ type: 'go' }) + expect(m.state).toBe('busy') + expect(m.hasTag('working')).toBe(true) + expect(m.context).toEqual({ a: 0, b: 7 }) + expect(calls.field).toBe(1) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('treats the top bit as "every field from highField up"', () => { + const fields = Array.from({ length: 32 }, (_, i) => `f${i}`) + const { handle, state, respond } = fake({ fields }) + const m = fromWasm<'idle', Record, { type: 'go' }>(handle) + respond(() => { + state.values = fields.map((_, i) => (i >= 29 ? i : 0)) + return FIELD(29) + }) + m.send({ type: 'go' }) + expect([m.context.f28, m.context.f29, m.context.f30, m.context.f31]).toEqual([0, 29, 30, 31]) + }) + + it('runs timer commands on the host clock and feeds fired timers back', () => { + const { handle, state, respond } = fake() + const { scheduler, timers } = manualScheduler() + const m = fromWasm<'idle' | 'busy', { a: number; b: number }, { type: 'go' }>(handle, { + scheduler, + }) + respond(() => { + state.commands = [1, 7, 150, 1, 8, 300] + return COMMANDS + }) + m.send({ type: 'go' }) + expect([...timers.values()].map(t => t.ms)).toEqual([150, 300]) + respond(() => { + state.commands = [2, 8, 0] + return COMMANDS + }) + m.send({ type: 'go' }) + expect(timers.size).toBe(1) + timers.get(1)!.fn() + expect(handle.fireTimer).toHaveBeenCalledWith(7) + }) + + it('caches computed values until a change, and remaps a source value once per version', () => { + const { handle, state, calls, respond } = fake() + const own = ['x', 'y', 'z'] + const m = fromWasm< + 'idle', + { a: number; b: number }, + { type: 'go' }, + { indices: string[]; other: string } + >(handle, { + computed: { indices: raw => (raw as number[]).map(i => own[i]!) }, + }) + expect(m.computed.indices).toEqual(['z', 'x']) + expect(m.computed.indices).toBe(m.computed.indices) + expect([calls.version, calls.computed]).toEqual([1, 1]) + + respond(() => FIELD(0)) // a change, same computed version + m.send({ type: 'go' }) + void m.computed.indices + expect([calls.version, calls.computed]).toEqual([2, 1]) + + respond(() => { + state.version = 2 + return FIELD(0) + }) + m.send({ type: 'go' }) + void m.computed.indices + expect(calls.computed).toBe(2) + }) + + it('fires start listeners on start (immediately for late ones) and stop listeners on stop', () => { + const { handle } = fake() + const m = fromWasm<'idle', { a: number; b: number }, { type: 'go' }>(handle) + const early = vi.fn() + const stopped = vi.fn() + m.onStart(early) + m.onStop(stopped) + m.start() + m.start() + const late = vi.fn() + m.onStart(late) + m.stop() + expect([early.mock.calls.length, late.mock.calls.length, stopped.mock.calls.length]).toEqual([ + 1, 1, 1, + ]) + }) +}) diff --git a/packages/wasm/tsconfig.json b/packages/wasm/tsconfig.json new file mode 100644 index 0000000..3fd6989 --- /dev/null +++ b/packages/wasm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "tests"], + "exclude": ["**/node_modules", "**/dist"] +} From 29f7dcf2fb19edcd2d091b94539c466c68e30d83 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:11:24 +0200 Subject: [PATCH 05/28] feat(benchmark): TS core vs Rust core (wasm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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= 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 --- benchmark/index.ts | 4 + benchmark/package.json | 4 +- benchmark/tests/rendering/bench.tsx | 77 +++++++- benchmark/tests/wasm.ts | 295 ++++++++++++++++++++++++++++ benchmark/wasm-built.ts | 12 ++ 5 files changed, 385 insertions(+), 7 deletions(-) create mode 100644 benchmark/tests/wasm.ts create mode 100644 benchmark/wasm-built.ts diff --git a/benchmark/index.ts b/benchmark/index.ts index 05db838..de5c655 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -21,6 +21,7 @@ import { runEngine } from './tests/engine' import { runConstruct } from './tests/construct' import { runMemory } from './tests/memory' import { runRendering } from './tests/rendering/run' +import { WASM_SKIPPED, wasmBuilt } from './wasm-built' async function main() { console.log('Benchmark suite (disposable). Node', process.version) @@ -33,6 +34,9 @@ async function main() { await runEngine() await runConstruct() await runMemory() + // The Rust/wasm comparison loads the built wasm, so it is imported only when it exists. + if (wasmBuilt()) await (await import('./tests/wasm')).runWasm() + else console.warn(`\n${WASM_SKIPPED}`) await runRendering() // last: imports react-dom (bootstraps its own jsdom) console.log('\n========== DONE ==========') } diff --git a/benchmark/package.json b/benchmark/package.json index eae77d5..ea686bb 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -7,8 +7,10 @@ "benchmark": "NODE_ENV=production node --expose-gc --import tsx index.ts" }, "dependencies": { + "@dunky.dev/demo-wasm": "workspace:*", "@dunky.dev/react-state-machine": "workspace:^", - "@dunky.dev/state-machine": "workspace:^" + "@dunky.dev/state-machine": "workspace:^", + "@sandbox/cmdk-core": "workspace:*" }, "devDependencies": { "@types/jsdom": "^21.1.7", diff --git a/benchmark/tests/rendering/bench.tsx b/benchmark/tests/rendering/bench.tsx index f89cc8a..4f91786 100644 --- a/benchmark/tests/rendering/bench.tsx +++ b/benchmark/tests/rendering/bench.tsx @@ -38,6 +38,8 @@ import { import { useSelector as useXSelector } from '@xstate/react' import { machine, type Machine } from '@dunky.dev/state-machine' import { useSelector } from '@dunky.dev/react-state-machine' +import { readFileSync, writeFileSync } from 'node:fs' +import { WASM_SKIPPED, wasmBuilt } from '../../wasm-built' type Ctx = { highlighted: number } type Ev = { type: 'move'; to: number } @@ -112,12 +114,24 @@ const renderCounts = { 'core/instance': 0, 'zag/instance': 0, 'xstate/selector': 0, + // The same rows over the Rust core (crates/demo → wasm, via @dunky.dev/state-machine-wasm). + 'wasm/selector': 0, + 'wasm/instance': 0, } -// Fine-grained row: re-renders only when ITS highlighted-ness flips. -function SelectorRow({ m, index }: { m: Machine<'idle', Ctx, Ev>; index: number }) { +// Fine-grained row: re-renders only when ITS highlighted-ness flips. `counter` +// lets the wasm arena reuse the very same row over a Rust machine. +function SelectorRow({ + m, + index, + counter = 'selector', +}: { + m: Machine<'idle', Ctx, Ev> + index: number + counter?: 'selector' | 'wasm/selector' +}) { const isHL = useSelector(m, () => m.context.highlighted === index) - renderCounts.selector++ + renderCounts[counter]++ return
{index}
} @@ -131,9 +145,15 @@ function NaiveRow({ m, index }: { m: Machine<'idle', Ctx, Ev>; index: number }) } // Per-instance core row: each owns its machine; re-renders only when its own bool flips. -function CoreInstanceRow({ m }: { m: ReturnType }) { +function CoreInstanceRow({ + m, + counter = 'core/instance', +}: { + m: ReturnType + counter?: 'core/instance' | 'wasm/instance' +}) { const on = useSelector(m, () => m.context.on) - renderCounts['core/instance']++ + renderCounts[counter]++ return
} @@ -158,7 +178,7 @@ function XStateSelectorRow({ actor, index }: { actor: XActor; index: number }) { return
{index}
} -type Strategy = 'selector' | 'naive' | 'core/instance' | 'zag/instance' | 'xstate/selector' +type Strategy = keyof typeof renderCounts type Row = { mount: number; mountMs: number; renders: number; ms: number } export async function runRenderingBench(N: number, moves: number) { @@ -298,6 +318,51 @@ export async function runRenderingBench(N: number, moves: number) { actor.stop() } + // --- The same two arenas over the Rust core (wasm), when it is built --- + const demo = wasmBuilt() ? await import('@dunky.dev/demo-wasm/node') : null + if (!demo) console.warn(WASM_SKIPPED) + if (demo) { + demo.loadDemoNode() // idempotent; the suite's wasm section already loaded it + const { createHighlight, createList } = demo + const m = createList() + m.start() + results['wasm/selector'] = run( + 'wasm/selector', + () => + Array.from({ length: N }, (_, i) => ( + + )), + k => m.send({ type: 'move', to: k % N }), + ) + m.stop() + + const ms_ = Array.from({ length: N }, (_, i) => createHighlight(i === 0)) + ms_.forEach(m => m.start()) + let cur = 0 + results['wasm/instance'] = run( + 'wasm/instance', + () => ms_.map((m, i) => ), + k => { + const next = k % N + ms_[cur].send({ type: 'set', on: false }) + ms_[next].send({ type: 'set', on: true }) + cur = next + }, + ) + ms_.forEach(m => m.stop()) + } + + // SPIKE_RENDER_OUT=: also write these rows as JSON (the spike report). + if (process.env.SPIKE_RENDER_OUT) { + const file = process.env.SPIKE_RENDER_OUT + let prev: Record = {} + try { + prev = JSON.parse(readFileSync(file, 'utf8')) as Record + } catch {} + prev[String(N)] = results + writeFileSync(file, JSON.stringify(prev, null, 2)) + } + console.log(`\n### Rendering — list of ${N}: first render (mount) + ${moves} highlight moves`) // NOTE on the `mount (ms)` column: it is NOT apples-to-apples across rows — it // reflects each library's idiomatic per-row primitive, which is the only fair diff --git a/benchmark/tests/wasm.ts b/benchmark/tests/wasm.ts new file mode 100644 index 0000000..075d234 --- /dev/null +++ b/benchmark/tests/wasm.ts @@ -0,0 +1,295 @@ +/** + * SPIKE — the TS core vs the Rust core (crates/core) compiled to wasm. + * + * Every Rust row runs the SAME machine as its TS row (crates/demo ports them 1:1): + * + * wasm/adapter — through @dunky.dev/state-machine-wasm: the TS `Machine` interface + * over the wasm handle (JS context mirror, change mask, timers). This + * is what a React/Solid target actually calls. + * wasm/raw — the bare wasm-bindgen handle, no adapter: the boundary floor. + * + * Needs the wasm build (`pnpm build:wasm`). Set SPIKE_OUT= to also write the + * numbers as JSON (the spike report reads them). + * + * Exported as `runWasm()`; the suite runs it via benchmark/index.ts. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { Bench } from 'tinybench' +import { machine } from '@dunky.dev/state-machine' +import { commandPaletteMachineConfig, type Command } from '@sandbox/cmdk-core' +import { + createCell, + createGuards, + createPalette, + createPaletteObjects, + createPingPong, + loadDemoNode, + noop, + raw, +} from '@dunky.dev/demo-wasm/node' +import { report } from '../report' + +const SINK = { n: 0 } +const bump = () => { + SINK.n++ +} + +interface Row { + name: string + hz: number + meanUs: number + rme: number +} +interface Section { + id: string + title: string + rows: Row[] +} +const sections: Section[] = [] + +async function run(id: string, title: string, bench: Bench): Promise { + await bench.warmup() + await bench.run() + report(title, bench) + sections.push({ + id, + title, + rows: bench.tasks.map(t => ({ + name: t.name, + hz: t.result?.hz ?? 0, + meanUs: (t.result?.mean ?? 0) * 1000, + rme: t.result?.rme ?? 0, + })), + }) +} + +const newBench = () => new Bench({ time: 500, warmupTime: 100 }) + +// --- TS twins of the Rust demo machines (crates/demo) ------------------------ +type CellCtx = { value: number; other: number } +const cellConfig = { + initial: 'idle' as const, + context: { value: 0, other: 0 }, + states: { + idle: { + on: { + hit: { + actions: [ + ({ + context, + setContext, + }: { + context: CellCtx + setContext: (p: Partial) => void + }) => setContext({ value: context.value + 1 }), + ], + }, + miss: { + actions: [ + ({ + context, + setContext, + }: { + context: CellCtx + setContext: (p: Partial) => void + }) => setContext({ other: context.other + 1 }), + ], + }, + }, + }, + }, +} +const tsCell = () => machine<'idle', CellCtx, { type: 'hit' | 'miss' }>(cellConfig) + +const pingPongConfig = { + initial: 'ping' as const, + context: {}, + states: { + ping: { entry: [bump], exit: [bump], on: { go: { target: 'pong' as const } } }, + pong: { entry: [bump], exit: [bump], on: { go: { target: 'ping' as const } } }, + }, +} +const tsPingPong = () => machine<'ping' | 'pong', object, { type: 'go' }>(pingPongConfig) + +function tsGuards(k: number) { + const candidates = Array.from({ length: k }, (_, i) => ({ + guard: ({ context }: { context: { pick: number } }) => context.pick === i, + actions: [bump], + })) + return machine<'idle', { pick: number }, { type: 'go' }>({ + initial: 'idle', + context: { pick: k - 1 }, + states: { idle: { on: { go: candidates } } }, + }) +} + +// Synthetic command sets with realistic labels for the subsequence filter. +const VERBS = ['Go to', 'Open', 'Create', 'Toggle', 'Invite', 'Archive', 'Rename', 'Share'] +const NOUNS = ['Dashboard', 'Issue', 'Pull Request', 'Settings', 'Project', 'Teammate', 'Board'] +function makeCommands(n: number): Command[] { + return Array.from({ length: n }, (_, i) => ({ + id: `cmd-${i}`, + label: `${VERBS[i % VERBS.length]} ${NOUNS[(i * 7) % NOUNS.length]} ${i}`, + group: NOUNS[i % NOUNS.length], + })) +} +const QUERIES = ['go', 'gpr', 'set', 'pro 1', 'to b', ''] + +// --- A. boundary floor --------------------------------------------------------- +function benchBoundary() { + const b = newBench() + const jsNoop = (x: number) => x + let x = 0 + b.add('js: function call', () => { + x = jsNoop(x + 1) & 0xffff + }) + b.add('wasm: noop(u32) call', () => { + x = noop(x + 1) & 0xffff + }) + return b +} + +// --- B. one event, observed field (the single-event throughput shape) ---------- +function benchCell(field: 'hit' | 'miss') { + const b = newBench() + const ts = tsCell() + ts.start() + ts.select.context('value').subscribe(bump) + const rs = createCell() + rs.start() + rs.select.context('value').subscribe(bump) + const handle = new raw.CellMachine() + handle.start() + const kind = field === 'hit' ? 0 : 1 + const event = { type: field } as const + b.add('ts core', () => ts.send(event)) + b.add('wasm/adapter', () => rs.send(event)) + b.add('wasm/raw sendKind', () => { + SINK.n += handle.sendKind(kind) + }) + return b +} + +// --- C. state churn: exit + entry actions every event -------------------------- +function benchPingPong() { + const b = newBench() + const ts = tsPingPong() + ts.start() + const rs = createPingPong() + rs.start() + const handle = new raw.PingPongMachine() + handle.start() + const go = { type: 'go' } as const + b.add('ts core', () => ts.send(go)) + b.add('wasm/adapter', () => rs.send(go)) + b.add('wasm/raw sendKind', () => { + SINK.n += handle.sendKind(0) + }) + return b +} + +// --- D. guard fallthrough, 8 candidates, the last wins -------------------------- +function benchGuards(k: number) { + const b = newBench() + const ts = tsGuards(k) + ts.start() + const rs = createGuards(k) + rs.start() + const go = { type: 'go' } as const + b.add('ts core', () => ts.send(go)) + b.add('wasm/adapter', () => rs.send(go)) + return b +} + +// --- E. palette: type a query, read results + activeId (computed) --------------- +function benchPalette(n: number) { + const b = newBench() + const commands = makeCommands(n) + const ts = machine(commandPaletteMachineConfig({ commands })) + ts.start() + ts.send({ type: 'open' }) + const rsIdx = createPalette(commands) + rsIdx.start() + rsIdx.send({ type: 'open' }) + const rsObj = createPaletteObjects(commands) + rsObj.start() + rsObj.send({ type: 'open' }) + let i = 0 + const typeAndRead = (m: typeof ts | typeof rsIdx) => { + m.send({ type: 'query.set', query: QUERIES[i++ % QUERIES.length]! }) + SINK.n += m.computed.results.length + (m.computed.activeId?.length ?? 0) + } + b.add('ts core', () => typeAndRead(ts)) + b.add('wasm/adapter (indices → own objects)', () => typeAndRead(rsIdx)) + b.add('wasm/adapter (objects cross)', () => typeAndRead(rsObj)) + return b +} + +// --- F. construction: build + start one machine --------------------------------- +function benchConstruct() { + const b = newBench() + b.add('ts core: machine(config) + start', () => { + const m = tsCell() + m.start() + SINK.n += m.context.value + }) + b.add('wasm/adapter: createCell() + start', () => { + const m = createCell() + m.start() + SINK.n += m.context.value + }) + b.add('wasm/raw: new CellMachine() + start', () => { + const h = new raw.CellMachine() + SINK.n += h.start() + }) + return b +} + +function measureInit(): { compileMs: number; firstInitMs: number } { + const url = new URL('../../packages/demo-wasm/pkg/dunky_demo_wasm_bg.wasm', import.meta.url) + const bytes = readFileSync(url) + const runs = 20 + const t0 = performance.now() + for (let i = 0; i < runs; i++) new WebAssembly.Module(bytes) + const compileMs = (performance.now() - t0) / runs + const t1 = performance.now() + loadDemoNode() + const firstInitMs = performance.now() - t1 + return { compileMs, firstInitMs } +} + +export async function runWasm() { + console.log('\n========== SPIKE — TS core vs Rust core (wasm) ==========') + const init = measureInit() + console.log( + `wasm init: compile ${init.compileMs.toFixed(2)} ms (avg of 20) · first initSync ${init.firstInitMs.toFixed(2)} ms`, + ) + await run('boundary', 'A. boundary floor', benchBoundary()) + await run('cell-hit', 'B. one event, observed field (hit)', benchCell('hit')) + await run('cell-miss', 'B2. one event, unobserved field (miss)', benchCell('miss')) + await run('pingpong', 'C. state churn (exit + entry actions)', benchPingPong()) + await run('guards-8', 'D. guard fallthrough (8 candidates)', benchGuards(8)) + for (const n of [11, 1_000, 10_000]) { + await run( + `palette-${n}`, + `E. palette: type + read computed (${n.toLocaleString()} commands)`, + benchPalette(n), + ) + } + await run('construct', 'F. construct + start one machine', benchConstruct()) + console.log('(anti-DCE SINK:', SINK.n, ')') + + const out = process.env.SPIKE_OUT + if (out) { + writeFileSync( + out, + JSON.stringify( + { node: process.version, date: new Date().toISOString(), init, sections }, + null, + 2, + ), + ) + console.log(`spike results written to ${out}`) + } +} diff --git a/benchmark/wasm-built.ts b/benchmark/wasm-built.ts new file mode 100644 index 0000000..dc68314 --- /dev/null +++ b/benchmark/wasm-built.ts @@ -0,0 +1,12 @@ +import { existsSync } from 'node:fs' + +/** + * Whether the demo machines were built to wasm (`pnpm build:wasm`). The wasm rows are + * skipped when they were not, so the TS benchmarks run without a Rust toolchain. + */ +export function wasmBuilt(): boolean { + return existsSync(new URL('../packages/demo-wasm/pkg/dunky_demo_wasm_bg.wasm', import.meta.url)) +} + +export const WASM_SKIPPED = + '⚠️ wasm not built — skipping the Rust/wasm rows (run `pnpm build:wasm`).' From ca1655cfb8980b068d96b988671ae4d9b82aa414 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:11:26 +0200 Subject: [PATCH 06/28] feat(sandbox): run the palette on the Rust engine (React, Solid, opentui) 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 --- sandbox/README.md | 21 ++++++++++++++++++- sandbox/opentui/package.json | 4 +++- sandbox/opentui/src/app.tsx | 9 +++++--- sandbox/opentui/src/command-palette.tsx | 10 ++++++--- sandbox/opentui/src/index.tsx | 17 +++++++++++++-- sandbox/react/package.json | 1 + sandbox/react/src/app.tsx | 13 ++++++++++-- sandbox/react/src/command-palette.tsx | 15 +++++++------ sandbox/react/src/main.tsx | 28 +++++++++++++++++++------ sandbox/react/vite.config.ts | 3 +++ sandbox/shared/src/index.ts | 1 + sandbox/shared/src/types.ts | 13 ++++++++++++ sandbox/solid/package.json | 1 + sandbox/solid/src/app.tsx | 13 ++++++++++-- sandbox/solid/src/command-palette.tsx | 13 +++++------- sandbox/solid/src/main.tsx | 20 ++++++++++++++++-- sandbox/solid/vite.config.ts | 3 +++ 17 files changed, 147 insertions(+), 38 deletions(-) diff --git a/sandbox/README.md b/sandbox/README.md index 4f45dc5..c6d0996 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -38,8 +38,27 @@ pnpm -C sandbox/solid dev pnpm -C sandbox/opentui dev # Native — needs Expo + an iOS/Android simulator or device. -pnpm -C sandbox/native start # then press i / a, or scan the QR +pnpm -C sandbox/native dev # then press i / a, or scan the QR ``` All four consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. + +## The same palette on the Rust core + +Every sandbox can also run the palette from `crates/demo` (the Rust port of +`shared/src/machine.ts`) — same `connect()`, same markup, only the machine differs: + +```bash +pnpm build:wasm # once: Rust → wasm (see CONTRIBUTING.md) +pnpm -C sandbox/react dev # then open http://localhost:5173/?engine=rust +pnpm -C sandbox/solid dev # then open http://localhost:5173/?engine=rust +pnpm -C sandbox/opentui dev:rust # Bun runs the wasm + +pnpm -C packages/native-rust ubrn:ios # React Native: Rust → native code over JSI +pnpm -C packages/native-rust ubrn:android +pnpm -C sandbox/native ios # then tap "Engine: TypeScript" to switch +``` + +Without those builds the TS engine still runs everywhere; the Rust switch is the only +thing that needs them. diff --git a/sandbox/opentui/package.json b/sandbox/opentui/package.json index d80f031..8e27cdb 100644 --- a/sandbox/opentui/package.json +++ b/sandbox/opentui/package.json @@ -4,9 +4,11 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run src/index.tsx" + "dev": "bun run src/index.tsx", + "dev:rust": "bun run src/index.tsx --engine=rust" }, "dependencies": { + "@dunky.dev/demo-wasm": "workspace:^", "@dunky.dev/opentui-state-machine": "workspace:^", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", diff --git a/sandbox/opentui/src/app.tsx b/sandbox/opentui/src/app.tsx index 1cf0a55..3f7bb71 100644 --- a/sandbox/opentui/src/app.tsx +++ b/sandbox/opentui/src/app.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +export type Engine = 'ts' | 'rust' + +export function App({ engine, source }: { engine: Engine; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -22,7 +24,8 @@ export function App() { NAMED ANSI color ('green'), which the terminal maps to its own palette. */} ⌘K Command Pallete - setLast(c.label)} /> + setLast(c.label)} /> + Engine: {engine === 'rust' ? 'Rust (wasm)' : 'TypeScript'} One state machine drives this ⌘K palette. diff --git a/sandbox/opentui/src/command-palette.tsx b/sandbox/opentui/src/command-palette.tsx index f8ae000..8d215ed 100644 --- a/sandbox/opentui/src/command-palette.tsx +++ b/sandbox/opentui/src/command-palette.tsx @@ -7,9 +7,9 @@ import type { KeyEvent } from '@opentui/core' import { useMachine } from '@dunky.dev/react-state-machine' import { normalize } from '@dunky.dev/opentui-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -54,8 +54,12 @@ const paletteKeys = (machine: CommandPaletteMachine) => (key: KeyEvent) => { // the SAME shared machine, `connect` produces the SAME logical bindings — only // `normalize` (from the opentui package) and the JSX elements differ. The index // math, filtering, and selection all live in the shared machine, unchanged. -export function CommandPalette(props: CommandPaletteProps) { - const { api, machine } = useMachine(commandPaletteMachineConfig, connectCommandPalette, [], props) +// `source` is the TS config or the Rust machine (see index.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api, machine } = useMachine(source, connectCommandPalette, [], props) // Starts closed — press Ctrl+K to open. (⌘K can't be used in a terminal: // macOS/Ghostty don't forward Cmd to the program — Cmd is an app/OS modifier — diff --git a/sandbox/opentui/src/index.tsx b/sandbox/opentui/src/index.tsx index 45724b3..1d32a32 100644 --- a/sandbox/opentui/src/index.tsx +++ b/sandbox/opentui/src/index.tsx @@ -1,6 +1,19 @@ import { createCliRenderer } from '@opentui/core' import { createRoot } from '@opentui/react' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Engine } from './app' + +// `--engine=rust` runs the same palette on the Rust machine (crates/demo → wasm; Bun +// runs WebAssembly). Build the wasm first: `pnpm build:wasm`. +const engine: Engine = process.argv.includes('--engine=rust') ? 'rust' : 'ts' + +let source: CommandPaletteSource = commandPaletteMachineConfig +if (engine === 'rust') { + // Imported only for the Rust engine, so the TS app runs without the wasm build. + const demo = await import('@dunky.dev/demo-wasm/node') + demo.loadDemoNode() + source = props => demo.createPalette(props.commands) +} const renderer = await createCliRenderer() -createRoot(renderer).render() +createRoot(renderer).render() diff --git a/sandbox/react/package.json b/sandbox/react/package.json index ffa4d77..f0b09fc 100644 --- a/sandbox/react/package.json +++ b/sandbox/react/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@dunky.dev/demo-wasm": "workspace:^", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 38b4005..7c004ea 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +export type Engine = 'ts' | 'rust' + +export function App({ engine, source }: { engine: Engine; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -10,6 +12,7 @@ export function App() {

⌘K Command Palette


{ setLast(c.label) @@ -25,6 +28,12 @@ export function App() {

Last selected: {last}

+

+ Engine: {engine === 'rust' ? 'Rust (wasm)' : 'TypeScript'} ·{' '} + + Switch to {engine === 'rust' ? 'TypeScript' : 'Rust'} + +

) } diff --git a/sandbox/react/src/command-palette.tsx b/sandbox/react/src/command-palette.tsx index 4b2746a..924a1d4 100644 --- a/sandbox/react/src/command-palette.tsx +++ b/sandbox/react/src/command-palette.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/react-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -28,13 +28,12 @@ const cmdkShortcut: ComponentEffect // machine, `connect` produces logical bindings, and `normalize` turns them into // DOM props (onPress→onClick, role/aria-*, etc). The component is just markup; // the look lives in the stylesheet shared with the Solid app. -export function CommandPalette(props: CommandPaletteProps) { - const { api } = useMachine( - commandPaletteMachineConfig, - connectCommandPalette, - [cmdkShortcut], - props, - ) +// `source` is the TS config or the Rust machine (see main.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api } = useMachine(source, connectCommandPalette, [cmdkShortcut], props) const inputRef = useRef(null) // Focus the input whenever the palette opens (a renderer concern, not the diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 4950347..09c2957 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Engine } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -8,8 +9,23 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -createRoot(root).render( - - - , -) +// `?engine=rust` runs the same palette on the Rust machine (crates/demo → wasm). +// Build the wasm first: `pnpm build:wasm`. +const engine: Engine = new URLSearchParams(location.search).get('engine') === 'rust' ? 'rust' : 'ts' + +async function boot(target: HTMLElement) { + let source: CommandPaletteSource = commandPaletteMachineConfig + if (engine === 'rust') { + // Imported only for the Rust engine, so the TS page runs without the wasm build. + const demo = await import('@dunky.dev/demo-wasm') + await demo.loadDemo() + source = props => demo.createPalette(props.commands) + } + createRoot(target).render( + + + , + ) +} + +void boot(root) diff --git a/sandbox/react/vite.config.ts b/sandbox/react/vite.config.ts index 26d9620..6db2aaa 100644 --- a/sandbox/react/vite.config.ts +++ b/sandbox/react/vite.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), + // The Rust engine (?engine=rust): the adapter and the demo machines built to wasm. + '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), + '@dunky.dev/demo-wasm': resolve(__dirname, '../../packages/demo-wasm/src'), }, }, }) diff --git a/sandbox/shared/src/index.ts b/sandbox/shared/src/index.ts index 2ae22e0..ea943d8 100644 --- a/sandbox/shared/src/index.ts +++ b/sandbox/shared/src/index.ts @@ -9,4 +9,5 @@ export type { CommandPaletteComputed, CommandPaletteState, CommandPaletteProps, + CommandPaletteSource, } from './types' diff --git a/sandbox/shared/src/types.ts b/sandbox/shared/src/types.ts index 46828ed..d833165 100644 --- a/sandbox/shared/src/types.ts +++ b/sandbox/shared/src/types.ts @@ -1,3 +1,5 @@ +import type { MachineSource } from '@dunky.dev/state-machine' + // ============================================================================= // cmdk — shared types // @@ -59,3 +61,14 @@ export interface CommandPaletteProps { /** Fired whenever the palette opens or closes. */ onOpenChange?: (open: boolean) => void } + +/** Where a renderer gets its palette machine: the TS config, or a ready machine (e.g. the + * Rust palette via wasm). Both seed from the same props and speak the same events. */ +export type CommandPaletteSource = ( + props: CommandPaletteProps, +) => MachineSource< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed +> diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json index e9ac5b6..7ff6265 100644 --- a/sandbox/solid/package.json +++ b/sandbox/solid/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@dunky.dev/demo-wasm": "workspace:^", "@dunky.dev/solid-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 3d9110c..76da964 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -1,8 +1,10 @@ import { createSignal } from 'solid-js' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +export type Engine = 'ts' | 'rust' + +export function App(props: { engine: Engine; source: CommandPaletteSource }) { const [last, setLast] = createSignal('—') return ( @@ -10,6 +12,7 @@ export function App() {

⌘K Command Palette


{ setLast(c.label) @@ -25,6 +28,12 @@ export function App() {

Last selected: {last()}

+

+ Engine: {props.engine === 'rust' ? 'Rust (wasm)' : 'TypeScript'} ·{' '} + + Switch to {props.engine === 'rust' ? 'TypeScript' : 'Rust'} + +

) } diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx index 74a5b1b..12a815e 100644 --- a/sandbox/solid/src/command-palette.tsx +++ b/sandbox/solid/src/command-palette.tsx @@ -1,9 +1,9 @@ import { createEffect, For, Show } from 'solid-js' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -27,13 +27,10 @@ const cmdkShortcut: ComponentEffect // machine and `normalize` maps the logical bindings to DOM props. The // component is just markup; the look lives in the stylesheet shared with the // React app. -export function CommandPalette(props: CommandPaletteProps) { - const { api } = useMachine( - commandPaletteMachineConfig, - connectCommandPalette, - [cmdkShortcut], - props, - ) +// `source` is the TS config or the Rust machine (see main.tsx), read once: the machine +// is built once, like any initial-only prop. +export function CommandPalette(props: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api } = useMachine(props.source, connectCommandPalette, [cmdkShortcut], props) let inputEl: HTMLInputElement | undefined diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index ed52a73..cab6a12 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -1,5 +1,6 @@ import { render } from '@solidjs/web' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Engine } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -7,4 +8,19 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -render(() => , root) +// `?engine=rust` runs the same palette on the Rust machine (crates/demo → wasm). +// Build the wasm first: `pnpm build:wasm`. +const engine: Engine = new URLSearchParams(location.search).get('engine') === 'rust' ? 'rust' : 'ts' + +async function boot(target: HTMLElement) { + let source: CommandPaletteSource = commandPaletteMachineConfig + if (engine === 'rust') { + // Imported only for the Rust engine, so the TS page runs without the wasm build. + const demo = await import('@dunky.dev/demo-wasm') + await demo.loadDemo() + source = props => demo.createPalette(props.commands) + } + render(() => , target) +} + +void boot(root) diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index eea5af4..1af9020 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -18,6 +18,9 @@ export default defineConfig({ '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), + // The Rust engine (?engine=rust): the adapter and the demo machines built to wasm. + '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), + '@dunky.dev/demo-wasm': resolve(__dirname, '../../packages/demo-wasm/src'), }, }, }) From 60306fec4d3e897c8e886f1ac1a498bccf95ac7f Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:11:38 +0200 Subject: [PATCH 07/28] feat(native): React Native on the Rust engine over JSI 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 --- crates/uniffi/.gitignore | 1 + crates/uniffi/Cargo.lock | 848 ++++++++++++++++++++++++++ crates/uniffi/Cargo.toml | 28 + crates/uniffi/src/lib.rs | 391 ++++++++++++ packages/native-rust/.gitignore | 7 + packages/native-rust/README.md | 82 +++ packages/native-rust/package.json | 43 ++ packages/native-rust/src/index.ts | 128 ++++ packages/native-rust/tsconfig.json | 7 + packages/native-rust/ubrn.config.yaml | 18 + sandbox/native/app.json | 1 + sandbox/native/app.tsx | 25 +- sandbox/native/command-palette.tsx | 10 +- sandbox/native/metro.config.js | 7 +- sandbox/native/package.json | 1 + sandbox/native/rust-source.ts | 17 + sandbox/native/tsconfig.json | 2 +- 17 files changed, 1608 insertions(+), 8 deletions(-) create mode 100644 crates/uniffi/.gitignore create mode 100644 crates/uniffi/Cargo.lock create mode 100644 crates/uniffi/Cargo.toml create mode 100644 crates/uniffi/src/lib.rs create mode 100644 packages/native-rust/.gitignore create mode 100644 packages/native-rust/README.md create mode 100644 packages/native-rust/package.json create mode 100644 packages/native-rust/src/index.ts create mode 100644 packages/native-rust/tsconfig.json create mode 100644 packages/native-rust/ubrn.config.yaml create mode 100644 sandbox/native/rust-source.ts diff --git a/crates/uniffi/.gitignore b/crates/uniffi/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/uniffi/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/uniffi/Cargo.lock b/crates/uniffi/Cargo.lock new file mode 100644 index 0000000..855fac3 --- /dev/null +++ b/crates/uniffi/Cargo.lock @@ -0,0 +1,848 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "dunky-core" +version = "0.1.0" +dependencies = [ + "dunky-macros", + "serde", +] + +[[package]] +name = "dunky-demo" +version = "0.1.0" +dependencies = [ + "dunky-core", + "serde", +] + +[[package]] +name = "dunky-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunky-uniffi" +version = "0.1.0" +dependencies = [ + "dunky-core", + "dunky-demo", + "dunky-wasm", + "serde_json", + "uniffi", +] + +[[package]] +name = "dunky-wasm" +version = "0.1.0" +dependencies = [ + "dunky-core", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uniffi" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec017b112701681f6fbbe5d92014b5c468eb0b177a94389de03ceec40665095" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4641669b48fefbc5e80ff08c5004d9c7617fb91232131a6734ab6712779cb04c" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8617ee814de22caf7417bf514715ba0b3f46bd9d5a5d794413fd8282cb737" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58d5b94fc92803d21b2928bd15c6f06e57609b95caf98ea561c99cda1b6d2a25" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032739b3ec725576914c15899dedaf080163ced86b6934566c20ec2b20ce90ca" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/uniffi/Cargo.toml b/crates/uniffi/Cargo.toml new file mode 100644 index 0000000..fe8cdf3 --- /dev/null +++ b/crates/uniffi/Cargo.toml @@ -0,0 +1,28 @@ +# React Native binding of the engine, built by uniffi-bindgen-react-native +# (packages/native-rust). Standalone on purpose: the root workspace excludes it, +# so its mobile-only toolchain can never break the other crates' builds. +[package] +name = "dunky-uniffi" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/dunky-dev/state-machine" +publish = false + +[lib] +name = "dunky_uniffi" +# staticlib: what ubrn links into the iOS xcframework and the Android turbo-module. +# cdylib: what ubrn's `jsi2` flavour loads instead, keeping that path open. +crate-type = ["cdylib", "staticlib"] + +[dependencies] +dunky-core = { path = "../core" } +dunky-demo = { path = "../demo" } +serde_json = "1" +# Must stay on the uniffi minor that uniffi-bindgen-react-native is built with +# (ubrn 0.31.0-6 -> uniffi 0.31): the generated C++/TS check the contract version. +uniffi = "=0.31" + +[dev-dependencies] +# The change-mask parity test drives the wasm binding's `Handle` next to this one. +dunky-wasm = { path = "../wasm" } diff --git a/crates/uniffi/src/lib.rs b/crates/uniffi/src/lib.rs new file mode 100644 index 0000000..90ecb7f --- /dev/null +++ b/crates/uniffi/src/lib.rs @@ -0,0 +1,391 @@ +//! React Native binding of the engine, exported over JSI by +//! uniffi-bindgen-react-native (see `packages/native-rust`). +//! +//! It speaks the handle protocol of `crates/wasm` (`dunky_wasm::Handle`), so one TS +//! adapter (`@dunky.dev/state-machine-wasm`) drives both bindings: events in by kind +//! index, a u32 change mask out, timers as flat commands. Values cross as JSON strings +//! instead of JS values. + +use std::mem::ManuallyDrop; +use std::sync::Arc; +use std::thread::{self, ThreadId}; + +use dunky_core::{ + Config, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, Types, +}; +use dunky_demo::palette::{self, Palette, PaletteEvent}; + +uniffi::setup_scaffolding!(); + +// The change mask and the command encoding are the shared protocol (the TS adapter +// decodes every binding with the same code): `dunky_core::protocol`. +use dunky_core::protocol::{FIELD_SHIFT, HIGH_FIELD, encode_commands, take_change_mask}; + +/// Bad input from JS, thrown as an `Error` with this message. +#[derive(Debug, uniffi::Error)] +#[uniffi(flat_error)] +pub enum MachineError { + Invalid(String), +} + +impl std::fmt::Display for MachineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self::Invalid(message) = self; + f.write_str(message) + } +} + +impl std::error::Error for MachineError {} + +/// Confines a `!Send` value to the thread that created it. uniffi objects must be +/// `Send + Sync`, but the engine is single-threaded (`Rc`/`RefCell`). JSI calls arrive +/// on the JS thread, so a call from any other thread is a bug: it panics instead of +/// racing. +struct ThreadBound { + owner: ThreadId, + value: ManuallyDrop, +} + +// SAFETY: `value` is only reachable through `get`, which panics off the owner thread, +// and `drop` never drops it on another thread. +unsafe impl Send for ThreadBound {} +unsafe impl Sync for ThreadBound {} + +impl ThreadBound { + fn new(value: T) -> Self { + Self { + owner: thread::current().id(), + value: ManuallyDrop::new(value), + } + } + + fn get(&self) -> &T { + assert!( + thread::current().id() == self.owner, + "[machine] called off the thread that created it" + ); + &self.value + } +} + +impl Drop for ThreadBound { + fn drop(&mut self) { + // Off the owner thread (a GC finalizer, say) the value leaks: freeing the `Rc` + // graph there would race the owner. + if thread::current().id() == self.owner { + // SAFETY: dropped once, here; `value` is never read again. + unsafe { ManuallyDrop::drop(&mut self.value) } + } + } +} + +/// The event type name at `kind`, or the error `dunky_wasm::Handle` throws for it. +fn event_name(kind: u32) -> Result<&'static str, MachineError> { + ::KIND_NAMES + .get(kind as usize) + .copied() + .ok_or_else(|| MachineError::Invalid(format!("[machine] no event type #{kind}"))) +} + +/// 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(); + let mut serializer = serde_json::Serializer::new(&mut *out); + if context.serialize_field(index, &mut serializer).is_err() { + out.truncate(start); + out.extend_from_slice(b"null"); + } +} + +/// Every field, in order, as a JSON array. +fn fields_json(context: &C) -> String { + let mut out = vec![b'[']; + for i in 0..C::FIELDS.len() { + if i > 0 { + out.push(b','); + } + write_field(context, i, &mut out); + } + out.push(b']'); + String::from_utf8(out).expect("serde_json writes UTF-8") +} + +/// The static description the TS adapter reads once per machine type. +fn meta_json(machine: &Machine) -> String { + let config = machine.config(); + let states = ::NAMES; + let events = ::KIND_NAMES; + let unit_events: Vec = (0..events.len()) + .map(|i| { + let kind = ::kind_from_index(i); + ::from_kind(kind).is_some() + }) + .collect(); + let tags: Vec<&[&str]> = (0..states.len()) + .map(|i| config.tags(::from_index(i))) + .collect(); + serde_json::json!({ + "states": states, + "events": events, + "unitEvents": unit_events, + "fields": ::FIELDS, + "computed": config.computed_names().collect::>(), + "tags": tags, + "fieldShift": FIELD_SHIFT, + "highField": HIGH_FIELD, + }) + .to_string() +} + +thread_local! { + // One config shared by every palette, as in crates/demo-wasm. Palettes never leave + // the JS thread, so a thread-local is enough. + static PALETTE: Config = palette::config(Vec::new()); +} + +/// The command palette of `crates/demo`, behind the handle protocol. +#[derive(uniffi::Object)] +pub struct PaletteMachine { + machine: ThreadBound>, +} + +#[uniffi::export] +impl PaletteMachine { + /// `commands_json`: the commands, a JSON array of `{ id, label, hint?, group? }`. + #[uniffi::constructor] + pub fn new(commands_json: String) -> Result, MachineError> { + let commands = serde_json::from_str(&commands_json) + .map_err(|e| MachineError::Invalid(format!("[machine] bad commands: {e}")))?; + let machine = + PALETTE.with(|config| Machine::with_context(config, palette::context(commands))); + Ok(Arc::new(Self { + machine: ThreadBound::new(machine), + })) + } + + /// 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); + 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); + Ok(take_change_mask(machine)) + } + + pub fn state_index(&self) -> u32 { + self.machine.get().state().index() as u32 + } + + /// Context field `index` as JSON. + pub fn field_json(&self, index: u32) -> String { + let mut out = Vec::new(); + write_field(&*self.machine.get().context(), index as usize, &mut out); + String::from_utf8(out).expect("serde_json writes UTF-8") + } + + /// Every context field, in order, as a JSON array (the adapter's initial mirror). + pub fn fields_json(&self) -> String { + fields_json(&*self.machine.get().context()) + } + + /// Computed `index` as JSON (`results`, `activeId`, `resultIndices`); `null` for + /// any other index. + pub fn computed_json(&self, index: u32) -> String { + let machine = self.machine.get(); + let id = index as usize; + let json = if id == palette::RESULTS.id() { + serde_json::to_string(&*machine.computed(palette::RESULTS)) + } else if id == palette::ACTIVE_ID.id() { + serde_json::to_string(&*machine.computed(palette::ACTIVE_ID)) + } else if id == palette::RESULT_INDICES.id() { + serde_json::to_string(&*machine.computed(palette::RESULT_INDICES)) + } else { + return "null".to_string(); + }; + json.unwrap_or_else(|_| "null".to_string()) + } + + /// Changes exactly when computed `index` changes. + pub fn computed_version(&self, index: u32) -> f64 { + self.machine.get().computed_version(index as usize) as f64 + } + + pub fn start(&self) -> u32 { + let machine = self.machine.get(); + machine.start(); + take_change_mask(machine) + } + + pub fn stop(&self) -> u32 { + let machine = self.machine.get(); + machine.stop(); + take_change_mask(machine) + } + + pub fn fire_timer(&self, id: u32) -> u32 { + let machine = self.machine.get(); + machine.fire_timer(id); + take_change_mask(machine) + } + + /// Timer commands since the last call: `[op, id, ms, …]`, op 1 = start, 2 = cancel. + pub fn take_commands(&self) -> Vec { + encode_commands(&self.machine.get().take_commands()) + } + + /// Names, payload-less events, tags and the mask layout, as JSON. + pub fn meta_json(&self) -> String { + meta_json(self.machine.get()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dunky_core::protocol::STATE_BIT; + use dunky_demo::palette::{MoveTo, PaletteCtx, PaletteEventKind}; + use serde_json::{Value, json}; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn kind(kind: PaletteEventKind) -> u32 { + PaletteEvent::kind_index(kind) as u32 + } + + fn palette() -> Arc { + PaletteMachine::new(serde_json::to_string(&palette::demo_commands()).unwrap()).unwrap() + } + + #[test] + fn thread_bound_refuses_other_threads_and_leaks_there() { + struct Flag(Arc); + impl Drop for Flag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let dropped = Arc::new(AtomicBool::new(false)); + let bound = ThreadBound::new(Flag(dropped.clone())); + let refused = thread::spawn(move || { + let refused = catch_unwind(AssertUnwindSafe(|| { + bound.get(); + })) + .is_err(); + drop(bound); + refused + }) + .join() + .unwrap(); + assert!(refused); + assert!(!dropped.load(Ordering::SeqCst)); + + drop(ThreadBound::new(Flag(dropped.clone()))); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[test] + fn change_masks_match_the_wasm_binding() { + let native = palette(); + let wasm = + dunky_wasm::Handle::new(Machine::new(&palette::config(palette::demo_commands()))); + let send = |event: PaletteEvent| { + wasm.machine.send(event); + wasm.changes() + }; + + assert_eq!(native.start(), wasm.start()); + let open = native.send_kind(kind(PaletteEventKind::Open)).unwrap(); + assert_eq!(open & STATE_BIT, STATE_BIT); + assert_eq!(open, send(PaletteEvent::Open)); + assert_eq!( + native + .send_payload_json(kind(PaletteEventKind::QuerySet), r#"{"query":"go"}"#.into()) + .unwrap(), + send(PaletteEvent::QuerySet { query: "go".into() }) + ); + assert_eq!( + native + .send_payload_json(kind(PaletteEventKind::Move), r#"{"to":"down"}"#.into()) + .unwrap(), + send(PaletteEvent::Move { to: MoveTo::Down }) + ); + assert_eq!( + native.send_kind(kind(PaletteEventKind::Execute)).unwrap(), + send(PaletteEvent::Execute) + ); + assert_eq!(native.stop(), wasm.stop()); + } + + #[test] + fn values_cross_as_json() { + let m = palette(); + m.start(); + m.send_kind(kind(PaletteEventKind::Open)).unwrap(); + m.send_payload_json( + kind(PaletteEventKind::QuerySet), + r#"{"type":"query.set","query":"gpr"}"#.into(), + ) + .unwrap(); + + assert_eq!(m.field_json(PaletteCtx::QUERY.index() as u32), r#""gpr""#); + assert_eq!( + m.field_json(PaletteCtx::LAST_EXECUTED.index() as u32), + "null" + ); + assert_eq!(m.computed_json(palette::RESULT_INDICES.id() as u32), "[2]"); + assert_eq!(m.computed_json(palette::ACTIVE_ID.id() as u32), r#""prs""#); + let fields: Value = serde_json::from_str(&m.fields_json()).unwrap(); + assert_eq!(fields.as_array().unwrap().len(), PaletteCtx::FIELDS.len()); + assert_eq!(fields[PaletteCtx::QUERY.index()], "gpr"); + } + + #[test] + fn bad_input_throws_instead_of_panicking() { + assert!(PaletteMachine::new("{".into()).is_err()); + let m = palette(); + assert!(m.send_kind(99).is_err()); + assert!(m.send_kind(kind(PaletteEventKind::QuerySet)).is_err()); + assert!( + m.send_payload_json(kind(PaletteEventKind::Highlight), r#"{"index":"x"}"#.into()) + .is_err() + ); + } + + #[test] + fn meta_describes_the_palette() { + let meta: Value = serde_json::from_str(&palette().meta_json()).unwrap(); + assert_eq!( + meta, + json!({ + "states": ["closed", "open"], + "events": ["open", "close", "query.set", "move", "highlight", "execute"], + "unitEvents": [true, true, false, false, false, true], + "fields": ["commands", "query", "activeIndex", "lastExecuted"], + "computed": ["results", "activeId", "resultIndices"], + "tags": [[], []], + "fieldShift": 2, + "highField": 29, + }) + ); + } +} diff --git a/packages/native-rust/.gitignore b/packages/native-rust/.gitignore new file mode 100644 index 0000000..1f22e71 --- /dev/null +++ b/packages/native-rust/.gitignore @@ -0,0 +1,7 @@ +# Generated by `pnpm ubrn:ios` / `pnpm ubrn:android` (see README.md) +/android/ +/build/ +/cpp/ +/ios/ +/src/generated/ +/NativeRust.podspec diff --git a/packages/native-rust/README.md b/packages/native-rust/README.md new file mode 100644 index 0000000..96ae99d --- /dev/null +++ b/packages/native-rust/README.md @@ -0,0 +1,82 @@ +# @dunky.dev/native-rust + +Private. The Rust engine for React Native. + +Hermes has no WebAssembly, so React Native cannot load the wasm build. Instead +it links the Rust crate [`crates/uniffi`](../../crates/uniffi) (`dunky-uniffi`) +as native code and calls it over JSI. The bindings and the turbo-module glue are +generated by +[uniffi-bindgen-react-native](https://jhugman.github.io/uniffi-bindgen-react-native/) +(ubrn). The Rust side speaks the handle protocol of `crates/wasm`, with values +as JSON strings, so the wasm adapter (`@dunky.dev/state-machine-wasm`) drives it +unchanged. + +``` +crates/uniffi (PaletteMachine: crates/demo's palette, uniffi proc-macros) + | + | pnpm ubrn:ios / pnpm ubrn:android + v +build/NativeRustFramework.xcframework static lib, iOS device + simulator +android/src/main/jniLibs// static lib, 4 Android ABIs +cpp/ ios/ android/ NativeRust.podspec JSI + turbo-module glue +src/generated/ TypeScript bindings + module entry + | + | src/index.ts: NativePaletteHandle -> fromWasm + v +a Machine for useMachine (sandbox/native, "Engine: Rust") +``` + +Only `src/index.ts`, `package.json`, `ubrn.config.yaml`, `tsconfig.json` and +this README are hand-written; everything else is generated and gitignored. + +```ts +import { createNativePalette } from '@dunky.dev/native-rust' + +// Same events, context and computed values as the TS palette config. +const source: CommandPaletteSource = props => createNativePalette(props.commands) +``` + +`loadNativeRust()` throws, with the reason, when the app does not carry the +module (Expo Go, or no ubrn build yet); `createNativePalette` calls it. A JS +reload does not pick up Rust changes: rebuild the libraries, then the app. + +## How to rebuild + +Once per machine: + +```sh +rustup target add aarch64-apple-ios aarch64-apple-ios-sim \ + aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android +cargo install cargo-ndk +``` + +iOS needs Xcode and CocoaPods. Android needs `ANDROID_HOME` with NDK +27.1.12297006, and JDK 17. + +After a change in `crates/uniffi` (or the crates it uses): + +```sh +cargo test --manifest-path crates/uniffi/Cargo.toml +pnpm -C packages/native-rust ubrn:ios # xcframework + bindings +pnpm -C packages/native-rust ubrn:android # jniLibs + bindings +``` + +These are debug builds; append `--release` for optimized Rust. The first run +also compiles the ubrn CLI. `pnpm -C packages/native-rust ubrn:clean` deletes +the generated files. + +Then rebuild the app. `pnpm -C sandbox/native ios` (`expo run:ios`) does all of +it; the explicit, non-interactive steps are: + +```sh +cd sandbox/native +pnpm exec expo prebuild --platform ios --no-install +(cd ios && pod install) +xcodebuild -workspace ios/cmdk.xcworkspace -scheme cmdk -configuration Debug \ + -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath ios/build/DerivedData build +pnpm exec expo start # Metro, for the debug build + +pnpm exec expo prebuild --platform android --no-install +(cd android && ./gradlew assembleDebug) +``` diff --git a/packages/native-rust/package.json b/packages/native-rust/package.json new file mode 100644 index 0000000..acec0a9 --- /dev/null +++ b/packages/native-rust/package.json @@ -0,0 +1,43 @@ +{ + "name": "@dunky.dev/native-rust", + "version": "0.0.0", + "private": true, + "description": "The Rust engine (crates/uniffi) for React Native, over JSI via uniffi-bindgen-react-native", + "homepage": "https://github.com/dunky-dev/state-machine/tree/main/packages/native-rust", + "license": "MIT", + "author": "Ivan Banov", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/native-rust" + }, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "scripts": { + "ubrn:ios": "ubrn build ios --config ubrn.config.yaml --and-generate", + "ubrn:android": "ubrn build android --config ubrn.config.yaml --and-generate", + "ubrn:clean": "rm -rf android build cpp ios src/generated NativeRust.podspec" + }, + "dependencies": { + "@dunky.dev/state-machine-wasm": "workspace:*", + "@sandbox/cmdk-core": "workspace:*", + "@ubjs/core": "0.31.0-6", + "uniffi-bindgen-react-native": "0.31.0-6" + }, + "peerDependencies": { + "react-native": "*" + }, + "codegenConfig": { + "name": "NativeRustSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "dev.dunky.nativerust" + } + } +} diff --git a/packages/native-rust/src/index.ts b/packages/native-rust/src/index.ts new file mode 100644 index 0000000..0f90877 --- /dev/null +++ b/packages/native-rust/src/index.ts @@ -0,0 +1,128 @@ +/** + * The Rust command palette (crates/demo, over JSI) behind the TypeScript `Machine` + * interface. + * + * ubrn generates `PaletteMachine` into ./generated: the wasm handle protocol, with + * values as JSON strings. `NativePaletteHandle` turns it back into a + * `WasmMachineHandle`, so `fromWasm` drives it unchanged. + */ +import { + fromWasm, + type WasmMachineHandle, + type WasmMachineMeta, +} from '@dunky.dev/state-machine-wasm' +import type { + Command, + CommandPaletteComputed, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteState, +} from '@sandbox/cmdk-core' +import type { PaletteMachine } from './generated' + +type Bindings = typeof import('./generated') + +let bindings: Bindings | undefined + +/** + * Loads the Rust module into the JS runtime, once. Throws, with the reason, when this + * app does not carry it (Expo Go, or no `pnpm ubrn:ios` / `pnpm ubrn:android` yet). + */ +export function loadNativeRust(): Bindings { + if (!bindings) { + // A require in try/catch, not an import: Metro then bundles a checkout where + // ./generated does not exist yet, and the error surfaces here instead. + try { + bindings = require('./generated') as Bindings + } catch (cause) { + throw new Error( + '[native-rust] cannot load the Rust module: build it (packages/native-rust: pnpm ubrn:ios / ubrn:android), then rebuild the app', + { cause }, + ) + } + } + return bindings +} + +/** The generated `PaletteMachine` as a `WasmMachineHandle`. A class on purpose: + * `fromWasm` caches the meta per handle prototype. */ +export class NativePaletteHandle implements WasmMachineHandle { + private readonly machine: PaletteMachine + + constructor(commands: readonly Command[]) { + this.machine = new (loadNativeRust().PaletteMachine)(JSON.stringify(commands)) + } + + sendKind(kind: number): number { + return this.machine.sendKind(kind) + } + + sendPayload(kind: number, event: unknown): number { + return this.machine.sendPayloadJson(kind, JSON.stringify(event)) + } + + sendEvent(event: unknown): number { + const { type } = event as { type: string } + const kind = this.meta().events.indexOf(type) + if (kind < 0) throw new Error(`[machine] no event type "${type}"`) + return this.sendPayload(kind, event) + } + + stateIndex(): number { + return this.machine.stateIndex() + } + + field(index: number): unknown { + return JSON.parse(this.machine.fieldJson(index)) + } + + fields(): unknown[] { + return JSON.parse(this.machine.fieldsJson()) as unknown[] + } + + computed(index: number): unknown { + return JSON.parse(this.machine.computedJson(index)) + } + + computedVersion(index: number): number { + return this.machine.computedVersion(index) + } + + start(): number { + return this.machine.start() + } + + stop(): number { + return this.machine.stop() + } + + fireTimer(id: number): number { + return this.machine.fireTimer(id) + } + + takeCommands(): Uint32Array { + return Uint32Array.from(this.machine.takeCommands()) + } + + meta(): WasmMachineMeta { + return JSON.parse(this.machine.metaJson()) as WasmMachineMeta + } +} + +/** The command palette on the Rust engine. As in `createPalette` (@dunky.dev/demo-wasm), + * `results` crosses as indices and maps back onto the caller's own command objects. */ +export function createNativePalette(commands: Command[]) { + return fromWasm< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed + >(new NativePaletteHandle(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => (raw as number[]).map(i => commands[i]!), + }, + }, + }) +} diff --git a/packages/native-rust/tsconfig.json b/packages/native-rust/tsconfig.json new file mode 100644 index 0000000..53ce585 --- /dev/null +++ b/packages/native-rust/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/native-rust/ubrn.config.yaml b/packages/native-rust/ubrn.config.yaml new file mode 100644 index 0000000..5f8e203 --- /dev/null +++ b/packages/native-rust/ubrn.config.yaml @@ -0,0 +1,18 @@ +# uniffi-bindgen-react-native: builds crates/uniffi for iOS/Android and +# generates the JSI bindings + turbo-module glue into this package. +# Reference: https://jhugman.github.io/uniffi-bindgen-react-native/reference/config-yaml.html +# +# Default targets: iOS device + arm64 simulator; all four Android ABIs, which +# the generated CMake build expects to match the app's reactNativeArchitectures. +rust: + directory: ../../crates/uniffi + manifestPath: Cargo.toml +ios: + frameworkName: build/NativeRustFramework +# All generated TypeScript goes under src/generated (gitignored); src/index.ts is +# the hand-written entry that loads it. +bindings: + ts: src/generated/bindings +turboModule: + entrypoint: src/generated/index.tsx + ts: src/generated diff --git a/sandbox/native/app.json b/sandbox/native/app.json index 9563fad..43c582e 100644 --- a/sandbox/native/app.json +++ b/sandbox/native/app.json @@ -6,6 +6,7 @@ "orientation": "portrait", "userInterfaceStyle": "light", "newArchEnabled": true, + "experiments": { "tsconfigPaths": false }, "ios": { "supportsTablet": true, "bundleIdentifier": "dev.dunky.cmdk" }, "android": { "package": "dev.dunky.cmdk" } } diff --git a/sandbox/native/app.tsx b/sandbox/native/app.tsx index b9794fe..13752ad 100644 --- a/sandbox/native/app.tsx +++ b/sandbox/native/app.tsx @@ -1,17 +1,25 @@ import { useState } from 'react' -import { Alert, StyleSheet, Text, View } from 'react-native' +import { Alert, Pressable, StyleSheet, Text, View } from 'react-native' import { StatusBar } from 'expo-status-bar' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { commandPaletteMachineConfig, DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' +import { rustSource } from './rust-source' + +type Engine = 'ts' | 'rust' export default function App() { const [last, setLast] = useState('—') + const [engine, setEngine] = useState('ts') + const rust = engine === 'rust' ? rustSource : null return ( ⌘K Command Pallete { setLast(c.label) @@ -23,6 +31,18 @@ export default function App() { The same machine + connect runs the terminal (OpenTUI) and React Native versions Last selected: {last} + setEngine(rust ? 'ts' : 'rust')} + > + + Engine: {rust ? 'Rust (JSI)' : 'TypeScript'} ·{' '} + {rustSource === null + ? 'Rust is not built into this app' + : `Switch to ${rust ? 'TypeScript' : 'Rust'}`} + + ) } @@ -39,4 +59,5 @@ const styles = StyleSheet.create({ title: { fontSize: 28, fontWeight: '700', letterSpacing: -0.5, color: '#1c1e26' }, lead: { maxWidth: 460, textAlign: 'center', color: '#5b6172', lineHeight: 24 }, hint: { fontSize: 16, fontWeight: '700', color: '#8990a0' }, + engine: { fontSize: 14, textAlign: 'center', color: '#3142c4' }, }) diff --git a/sandbox/native/command-palette.tsx b/sandbox/native/command-palette.tsx index dc18518..c059717 100644 --- a/sandbox/native/command-palette.tsx +++ b/sandbox/native/command-palette.tsx @@ -5,8 +5,8 @@ import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from import { useMachine } from '@dunky.dev/react-state-machine' import { mergeProps, normalize } from '@dunky.dev/native-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -15,8 +15,12 @@ import { // elements differ. There's no global keyboard on mobile, so the trigger is a tap // and rows are tapped to execute; arrow-key nav simply isn't part of this // substrate. The machine doesn't care — it just receives `highlight`/`execute`. -export function CommandPalette(props: CommandPaletteProps) { - const { api, machine } = useMachine(commandPaletteMachineConfig, connectCommandPalette, [], props) +// `source` is the TS config or the Rust machine (see app.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api, machine } = useMachine(source, connectCommandPalette, [], props) const inputRef = useRef(null) useEffect(() => { diff --git a/sandbox/native/metro.config.js b/sandbox/native/metro.config.js index 4746090..55e7008 100644 --- a/sandbox/native/metro.config.js +++ b/sandbox/native/metro.config.js @@ -22,11 +22,14 @@ config.resolver.nodeModulesPaths = [ // two React instances and "Invalid hook call" errors. Unlike webpack, Metro // doesn't dedupe, and extraNodeModules won't help because the wrong copy // resolves normally; we redirect every `react`/`react-dom` request to the -// app's copy via resolveRequest. +// app's copy via resolveRequest. Same for `react-native`: pnpm links +// @dunky.dev/native-rust its own copy (a different peer set), and its +// generated TurboModule spec must register with the app's runtime. const reactRoot = path.resolve(projectRoot, 'node_modules') +const singleInstance = ['react', 'react-native'] const defaultResolveRequest = config.resolver.resolveRequest config.resolver.resolveRequest = (context, moduleName, platform) => { - if (moduleName === 'react' || moduleName.startsWith('react/')) { + if (singleInstance.some(name => moduleName === name || moduleName.startsWith(`${name}/`))) { return context.resolveRequest( { ...context, originModulePath: path.join(reactRoot, 'noop.js') }, moduleName, diff --git a/sandbox/native/package.json b/sandbox/native/package.json index eab895a..3563c8e 100644 --- a/sandbox/native/package.json +++ b/sandbox/native/package.json @@ -9,6 +9,7 @@ "android": "expo run:android" }, "dependencies": { + "@dunky.dev/native-rust": "workspace:^", "@dunky.dev/native-state-machine": "workspace:^", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", diff --git a/sandbox/native/rust-source.ts b/sandbox/native/rust-source.ts new file mode 100644 index 0000000..0591f03 --- /dev/null +++ b/sandbox/native/rust-source.ts @@ -0,0 +1,17 @@ +import { createNativePalette, loadNativeRust } from '@dunky.dev/native-rust' +import type { CommandPaletteSource } from '@sandbox/cmdk-core' + +// The palette on the Rust engine (crates/demo over JSI), or null where this app has no +// Rust module: Expo Go, or a checkout that hasn't run `pnpm ubrn:ios` / `ubrn:android`. +// The TS palette must keep working there. +function rustPalette(): CommandPaletteSource | null { + try { + loadNativeRust() + } catch (error) { + console.warn(error) + return null + } + return props => createNativePalette(props.commands) +} + +export const rustSource = rustPalette() diff --git a/sandbox/native/tsconfig.json b/sandbox/native/tsconfig.json index 3a85ccd..9660e95 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-source.ts"] } From 6c79c0de19c1546928b55b64f5047c09fe0b98e1 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 5 Oct 2026 22:11:40 +0200 Subject: [PATCH 08/28] ci: Rust checks, wasm build, workspace wiring and docs - 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 --- .github/actions/wasm-setup/action.yml | 36 ++++++++ .github/workflows/ci-checks.yml | 30 +++++++ ARCHITECTURE.md | 38 ++++++-- CONTRIBUTING.md | 36 ++++++-- knip.config.ts | 4 + package.json | 3 + pnpm-lock.yaml | 121 ++++++++++++++++++++++++++ tsconfig.build.json | 2 + tsconfig.json | 1 + tsdown.config.ts | 1 + 10 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 .github/actions/wasm-setup/action.yml diff --git a/.github/actions/wasm-setup/action.yml b/.github/actions/wasm-setup/action.yml new file mode 100644 index 0000000..8453484 --- /dev/null +++ b/.github/actions/wasm-setup/action.yml @@ -0,0 +1,36 @@ +name: Setup wasm +description: Build the generated wasm package (packages/demo-wasm/pkg) for jobs that import it. 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 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 7874ee7..f7e8c0d 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -33,6 +33,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup + # The demo-wasm parity tests load the generated wasm package. + - uses: ./.github/actions/wasm-setup - run: pnpm test:ci typecheck: @@ -41,6 +43,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup + # demo-wasm (and the benchmark, through it) import the generated wasm package. + - uses: ./.github/actions/wasm-setup - run: pnpm typecheck lint: @@ -59,6 +63,32 @@ jobs: - uses: ./.github/actions/ci-setup - run: pnpm format:check + rust: + # 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 clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + # Again in release: overflow checks and debug assertions are off there, as + # in the shipped wasm. + - 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' diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54cec89..febaa50 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -93,12 +93,14 @@ Zag, whose machines read props directly.) ## Project structure -| File / location | What it owns | -| --------------------------- | ----------------------------------------------------------------- | -| `packages/core/` | State-machine engine (plain-mutation kernel) | -| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | -| `packages/shared/utils/` | mergeProps, composeHandlers | -| `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | +| File / location | What it owns | +| -------------------------------- | ----------------------------------------------------------------- | +| `packages/core/` | State-machine engine (plain-mutation kernel) | +| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | +| `packages/shared/utils/` | mergeProps, composeHandlers | +| `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | +| `crates/core/` | Rust port of the engine (experimental, same SPEC) | +| `crates/wasm/`, `packages/wasm/` | Rust machine → wasm → the TS `Machine` interface | ## The map @@ -172,6 +174,30 @@ whether it needs props/platform or not: platform listener is per-target. On accept it `send()`s a plain event the machine already understands. +## The Rust core (experimental) + +`crates/core` (`dunky-core`) is a Rust port of the engine. It implements the same +[SPEC](packages/core/SPEC.md); its tests are ported from `packages/core/tests`. +Machines are typed Rust (`#[derive(State, Event, Context)]`), the core owns no clock +(`after` timers are commands the host runs), and every call reports a change mask. + +``` +crates/core (Rust machines: engine + config + computed) + | + +-- crates/wasm --> .wasm + JS glue --> packages/wasm (fromWasm) + | | + | v + | the TS Machine interface: react, solid, native, opentui + | + +-- crates/uniffi --> native code + JSI (React Native; Hermes has no wasm) +``` + +A target never knows which engine runs: `useMachine` takes a config (TS engine) or a +ready machine (`toMachine`), and `fromWasm(handle)` returns the same `Machine` +interface. The adapter mirrors context into one plain JS object and re-reads only the +fields the change mask names; observers are notified once per call. Build the demo +machines with `pnpm build:wasm`; the spike numbers live in `benchmark/tests/wasm.ts`. + ## Vocabulary | Term | What it is | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eeb6a21..a958d0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,17 +13,31 @@ corepack enable pnpm install ``` +The Rust core (`crates/`) and the wasm demo machines need a Rust toolchain. The +typecheck and the wasm tests read the generated `packages/demo-wasm/pkg`, so build it +once after cloning (and after changing a crate): + +```bash +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version "$(grep -A1 'name = "wasm-bindgen"' Cargo.lock | sed -n 's/version = "\(.*\)"/\1/p')" +brew install binaryen # optional: wasm-opt, the size pass +pnpm build:wasm +``` + ## Commands -| Command | What it does | -| ------------------------------ | ---------------------------------------------------------- | -| `pnpm test` | Full test suite, watch mode | -| `pnpm test:ci` | Full test suite, once | -| `pnpm typecheck` | `tsc -b` across the whole workspace | -| `pnpm lint` | `oxlint` | -| `pnpm format` / `format:check` | `oxfmt` | -| `pnpm build` | Build every publishable package | -| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | +| Command | What it does | +| ------------------------------ | --------------------------------------------------------------- | +| `pnpm test` | Full test suite, watch mode | +| `pnpm test:ci` | Full test suite, once | +| `pnpm typecheck` | `tsc -b` across the whole workspace | +| `pnpm lint` | `oxlint` | +| `pnpm format` / `format:check` | `oxfmt` | +| `pnpm build` | Build every publishable package | +| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | +| `pnpm build:wasm` | Build the Rust demo machines to wasm (`packages/demo-wasm/pkg`) | +| `pnpm test:rust` | `cargo test` across the Rust workspace | +| `pnpm lint:rust` | `cargo clippy` (warnings are errors) + `cargo fmt --check` | Target one package or one file instead of the whole workspace: @@ -40,6 +54,10 @@ pnpm -C sandbox/opentui dev # terminal — needs Bun pnpm -C sandbox/native dev # Expo — needs a simulator or device ``` +The React and Solid sandboxes run the same palette on the Rust core with +`?engine=rust`; the opentui one with `pnpm -C sandbox/opentui dev:rust`. Build the +wasm first (`pnpm build:wasm`). + ## Filing an issue A bug report is only as useful as its reproduction. Use an **SSCCE** — Short, diff --git a/knip.config.ts b/knip.config.ts index 2d4e5d5..6a78922 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -11,6 +11,10 @@ const config: KnipConfig = { // `pnpm -C dev` is a workspace script name knip mistakes for a binary. ignoreBinaries: ['dev'], }, + 'packages/native-rust': { + // Imported by the generated (gitignored) uniffi bindings, which knip can't see. + ignoreDependencies: ['@ubjs/core'], + }, }, } diff --git a/package.json b/package.json index d835914..b101078 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", + "test:rust": "cargo test --workspace", "benchmark": "pnpm -C benchmark benchmark", "benchmark:demo": "pnpm -C benchmark/demo dev", "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", + "build:wasm": "./scripts/build-wasm.sh", "typecheck": "tsc -b tsconfig.build.json", "lint": "oxlint .", + "lint:rust": "cargo clippy --workspace --all-targets -- -D warnings && cargo fmt --all --check", "format": "oxfmt .", "format:check": "oxfmt --check .", "knip": "knip", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d1998c..da1edf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,12 +47,18 @@ importers: benchmark: dependencies: + '@dunky.dev/demo-wasm': + specifier: workspace:* + version: link:../packages/demo-wasm '@dunky.dev/react-state-machine': specifier: workspace:^ version: link:../packages/react '@dunky.dev/state-machine': specifier: workspace:^ version: link:../packages/core + '@sandbox/cmdk-core': + specifier: workspace:* + version: link:../sandbox/shared devDependencies: '@types/jsdom': specifier: ^21.1.7 @@ -124,6 +130,18 @@ importers: packages/core: {} + packages/demo-wasm: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:* + version: link:../core + '@dunky.dev/state-machine-wasm': + specifier: workspace:* + version: link:../wasm + '@sandbox/cmdk-core': + specifier: workspace:* + version: link:../../sandbox/shared + packages/dom: dependencies: '@dunky.dev/state-machine-bindings': @@ -151,6 +169,24 @@ importers: specifier: '*' version: 0.85.0(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6) + packages/native-rust: + dependencies: + '@dunky.dev/state-machine-wasm': + specifier: workspace:* + version: link:../wasm + '@sandbox/cmdk-core': + specifier: workspace:* + version: link:../../sandbox/shared + '@ubjs/core': + specifier: 0.31.0-6 + version: 0.31.0-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 + packages/opentui: dependencies: '@dunky.dev/state-machine-bindings': @@ -232,8 +268,17 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.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)) + packages/wasm: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:* + version: link:../core + sandbox/native: dependencies: + '@dunky.dev/native-rust': + specifier: workspace:^ + version: link:../../packages/native-rust '@dunky.dev/native-state-machine': specifier: workspace:^ version: link:../../packages/native @@ -274,6 +319,9 @@ importers: sandbox/opentui: dependencies: + '@dunky.dev/demo-wasm': + specifier: workspace:^ + version: link:../../packages/demo-wasm '@dunky.dev/opentui-state-machine': specifier: workspace:^ version: link:../../packages/opentui @@ -311,6 +359,9 @@ importers: sandbox/react: dependencies: + '@dunky.dev/demo-wasm': + specifier: workspace:^ + version: link:../../packages/demo-wasm '@dunky.dev/react-state-machine': specifier: workspace:^ version: link:../../packages/react @@ -357,6 +408,9 @@ importers: sandbox/solid: dependencies: + '@dunky.dev/demo-wasm': + specifier: workspace:^ + version: link:../../packages/demo-wasm '@dunky.dev/solid-state-machine': specifier: workspace:^ version: link:../../packages/solid @@ -3374,6 +3428,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==} @@ -6679,6 +6736,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==} @@ -9540,6 +9601,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 @@ -10097,6 +10167,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': @@ -13599,6 +13671,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 @@ -14476,6 +14595,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/tsconfig.build.json b/tsconfig.build.json index ba5a59a..7165b70 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,7 @@ "references": [ { "path": "./tsconfig.json" }, { "path": "./packages/core" }, + { "path": "./packages/wasm" }, { "path": "./packages/dom" }, { "path": "./packages/react" }, { "path": "./packages/solid" }, @@ -10,6 +11,7 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, + { "path": "./packages/demo-wasm" }, { "path": "./benchmark" } ] } diff --git a/tsconfig.json b/tsconfig.json index 8436124..0f11fdb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "strict": true, "paths": { "@dunky.dev/state-machine": ["./packages/core/src"], + "@dunky.dev/state-machine-wasm": ["./packages/wasm/src"], "@dunky.dev/react-state-machine": ["./packages/react/src"], "@dunky.dev/solid-state-machine": ["./packages/solid/src"], "@dunky.dev/native-state-machine": ["./packages/native/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 937d196..51d9e8b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ // choice. Keep in sync with the publish set in .changeset/config.json. workspace: [ 'packages/core', + 'packages/wasm', 'packages/dom', 'packages/react', 'packages/solid', From 4a59d376aab1d7c6b115d391b857cc28b40fafcb Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:09:00 +0200 Subject: [PATCH 09/28] feat(rust): host binding API in the engine 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 --- crates/core/Cargo.toml | 3 + crates/core/README.md | 37 +-- crates/core/SPEC.md | 154 +++++++++++ crates/core/src/computed.rs | 51 +++- crates/core/src/config.rs | 162 ++++++++++- crates/core/src/lib.rs | 13 +- crates/core/src/machine.rs | 515 +++++++++++++++++++++++++++-------- crates/core/src/selection.rs | 2 +- 8 files changed, 772 insertions(+), 165 deletions(-) create mode 100644 crates/core/SPEC.md diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7292ccf..f85edd2 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -13,6 +13,9 @@ default = ["derive", "serde"] derive = ["dep:dunky-macros"] # Per-field serialization (SerializeFields) for bindings that mirror context into JS. serde = ["dep:serde"] +# The binding API: the `Host` trait and the `External` parts it runs, so a runtime can +# drive machines written in another language (TS machines on the wasm / JSI runtimes). +host = [] [dependencies] dunky-macros = { workspace = true, optional = true } diff --git a/crates/core/README.md b/crates/core/README.md index 549c3c6..9ed943b 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -1,8 +1,9 @@ # dunky-core -The Rust port of [`@dunky.dev/state-machine`](../../packages/core). Same engine, same -behavior contract — [`packages/core/SPEC.md`](../../packages/core/SPEC.md) is the spec for -both; the Rust tests in `tests/` are ported from `packages/core/tests`. +The engine of [`@dunky.dev/state-machine`](../../packages/core), and the crate to write +machines in Rust. [`packages/core/SPEC.md`](../../packages/core/SPEC.md) is its behavior +contract; the Rust tests in `tests/` are ported from `packages/core/tests`. Machines +written in TypeScript run on it too (through `dunky-wasm`'s runtime). ```rust use dunky_core::{Config, Context, Event, Machine, State, Types}; @@ -38,26 +39,10 @@ dialog.send(DialogEvent::Open); assert_eq!(dialog.state(), DialogState::Open); ``` -## Where it differs from the TS engine - -- **Typed.** States, events and context are Rust types. `#[derive(Event)]` generates the - `Kind` enum that transitions are keyed by; `#[derive(Context)]` generates the - `Patch` builder (`Ctx::patch().field(value)`), one `Field` const per field, and a - tracked reader so computed values record what they read. -- **Sans-IO timers.** The core owns no clock. `after` emits `Command::StartTimer` / - `Command::CancelTimer` (`Machine::take_commands`); the host runs them and calls - `Machine::fire_timer(id)`. Tests use `testing::Clock`. -- **Change mask.** `Machine::take_changes` reports which fields (and whether the state) - changed, so a binding mirrors only those into another runtime. -- **Borrow discipline.** Actions, guards, effects, delays and computed definitions get - params, not the machine handle. Observers (subscriptions, selections, lifecycle - listeners, effect cleanups) run with no borrow held and may read, send or patch. -- **Equality** is `PartialEq` per field (TS: `Object.is`). -- **A missing named implementation** panics in debug builds and warns + no-ops in release - builds (TS: throws in dev, warns in prod). - -## Bindings - -- [`dunky-wasm`](../wasm): `export_machine!` turns a machine type into a JS class for - [`@dunky.dev/state-machine-wasm`](../../packages/wasm). -- `crates/uniffi` (React Native through JSI) — see its README. +## Learn more + +- [`SPEC.md`](./SPEC.md) — what the crate guarantees: authoring in Rust, timers as + commands, change reporting, and the binding API (cargo feature `host`). The behavior + contract every machine follows is [`packages/core/SPEC.md`](../../packages/core/SPEC.md). +- [`dunky-wasm`](../wasm) — export a machine to JavaScript; wrap it with `fromWasm` from + `@dunky.dev/state-machine`. diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md new file mode 100644 index 0000000..15781e9 --- /dev/null +++ b/crates/core/SPEC.md @@ -0,0 +1,154 @@ +# SPEC - `dunky-core` + +## Overview + +`dunky-core` is the engine of [`@dunky.dev/state-machine`](../../packages/core), in Rust. +Every machine runs on it: a machine written in Rust with this crate, and a machine +written in TypeScript, through the runtime in [`dunky-wasm`](../wasm/SPEC.md). + +The behavior contract is [`packages/core/SPEC.md`](../../packages/core/SPEC.md): flat +states, run-to-completion, guards, actions, effects, timed transitions, derived data, +data-reactions, observation, the connector, composition, and the cross-instance store. +This SPEC covers only what is specific to the crate: authoring a machine in Rust, timers +without a clock, and the binding API. + +``` + Rust types + config builder a binding (dunky-wasm, React Native) + | | host: runs the external parts, + | build once | hears every change + v | + config (shared by every machine of the type) + | | + v v + machine ------ timer commands out ------> host clock + ^ | | + | +-------- changes out (state, fields) | + | | + +----------- events in, due timers in <-----+ +``` + +## Intent + +Write the engine once and run it for every host: JS through wasm, React Native through +JSI, and Rust programs directly. The engine stays free of IO and platform, so one build +behaves the same everywhere; a host supplies the clock and the boundary. + +## Scope & boundaries + +**In scope.** The engine runtime; typed authoring of machines; timers as commands; change +reporting; and the binding API (cargo feature `host`). + +**Out of scope — invariants, not preferences.** + +- **No IO, no clock, no platform.** The engine never reads time and never schedules work. + A host does. +- **No JS.** Bindings live in their own crates: `dunky-wasm` for JS, `crates/uniffi` for + React Native. +- **One thread.** A machine and what it shares stay on one thread. A binding that crosses + threads enforces that itself. +- **At most 64 context fields** per machine: each field is one bit of the change mask. + +## Authoring a machine in Rust + +- A machine type names three types: its **states** (an enum without data), its + **events** (an enum: each variant is a kind, and a variant may carry a payload), and its + **context** (a struct). +- Derives generate the glue: the state names and indices; one event kind per variant, + which keys the handlers; and for the context, a patch builder that sets fields by name, + one handle per field, and a reader that records which fields a derived value reads. +- A **config** is built once, with a builder, from the initial state and the seed + context. It is cheap to share: every machine of the type uses the same config. +- Guards, actions, effects and delays are inline closures or registered names; a name + resolves when the config is built. +- A derived value is declared with a typed key, numbered in declaration order, and an + equality (`PartialEq` by default). +- With the `serde` feature, a context field serializes by index and an event rebuilds + from its kind and its payload: what a binding needs to cross into another runtime. + +## The behavior contract + +The contract is `packages/core/SPEC.md`'s. These guarantees are specific to the crate. + +### Timers are commands + +- Scheduling a timed transition emits a **start** command: a timer id and a delay. + Leaving the state emits a **cancel** command for each of its pending timers. +- The host takes the commands after each call, runs them on its clock, and reports each + due timer by id. A report for a cancelled, fired, or stale timer is ignored. +- A timer id is unique among the machine's pending timers. + +### Change reporting + +- Each effective change stamps what it touched: the state, or a set of context fields. +- A binding can take what changed since its last take — a state flag and a field mask — + to mirror only those values into another runtime. + +### Re-entrancy + +- User code written in Rust (guards, actions, effects, delays, derived definitions) gets + params — the context, the event, derived values, a way to write context, a way to + dispatch — not the machine. +- Observers (subscriptions, selections, lifecycle listeners, effect cleanups) and host + callbacks run while the engine holds no mutable borrow: they may read, dispatch, or + write context. A dispatch made during a run-to-completion step is queued. + +### Equality + +- A context write changes a field only when the value differs (`PartialEq`). A derived + value changes by its declared equality. + +### Failures + +- A missing named implementation panics in debug builds, and warns and does nothing in + release builds. +- A runaway step (more than 10,000 items in one run-to-completion step) stops. Without a + host it panics in debug builds and drops the queue in release builds. With a host, the + step stops and the binding raises the failure in its own runtime. +- A panicking cleanup does not leak the others: every cleanup runs, then the first panic + resumes. + +## The binding API + +A **host** (cargo feature `host`) connects a machine to another runtime. It has two jobs: + +- **Run the external parts.** A config may reference guards, actions, effects, delays + and derived definitions by number instead of Rust code — for example, the user code of + a machine written in TypeScript. The engine calls the host for them while it holds no + mutable borrow, so the host may read derived values, write context, and dispatch. A + run of external actions can be one host call, and so can a guard walk over a handler's + candidates (the host returns the first that passes). +- **Hear every change.** After each effective change the engine notifies the host: once + per change, in order, with no borrow held, with the current state and what changed + since the previous notify. A host may hold a notification back and deliver it with its + next callback, first, and no later than when the transition ends. + +The contract: + +- **Failure.** When a host callback fails, the host raises the machine's halt flag. The + engine stops the current step at its next checkpoint: no more user code runs in that + step, nothing more applies, and what is queued stays queued. The binding raises the + failure and lowers the flag. A derived value evaluated during a failure is not cached. +- **Context the host owns.** A host that keeps the context itself (the JS object of a TS + machine) reports which fields a write changed, and announces the write to its own + observers. The engine stamps the fields at once, or at its next safe point when it is + in the middle of a step, then runs the watchers; it does not notify for that write. +- **Dependencies of external derived values.** The host reports what an evaluation read + — context fields, the state, other derived values — and the engine tracks staleness + from that report. +- A machine written in Rust has no external parts: its host only hears changes. + +## Performance guarantees + +- Notifying observers in the steady state allocates nothing. +- A derived value is fresh by a stamp comparison: a read re-reads and compares no input + value. +- One config per machine type, shared by every instance. + +## Edge cases that carry design meaning + +- A host callback may dispatch; the event runs after the current item. +- A write a host makes in the middle of a step applies at the next safe point, before the + step ends, so watchers and notifications see it in order. +- A due timer reported after stop, after the state was left, or after the state was + entered again is ignored. +- A context with more than 64 fields is rejected when the machine is built. diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs index b848c09..f497fbd 100644 --- a/crates/core/src/computed.rs +++ b/crates/core/src/computed.rs @@ -16,9 +16,16 @@ use crate::traits::{Context, Types}; pub(crate) type ComputedEval = Rc Fn(&ComputedParams<'a, T>) -> Rc>; +pub(crate) enum ComputedSource { + Fn(ComputedEval), + /// Evaluated by the machine's host, by the binding's own id. + #[cfg(feature = "host")] + External(u32), +} + pub(crate) struct ComputedDef { pub(crate) name: &'static str, - pub(crate) eval: ComputedEval, + pub(crate) eval: ComputedSource, pub(crate) eq: fn(&dyn Any, &dyn Any) -> bool, } @@ -69,14 +76,20 @@ impl ComputedParams<'_, T> { self.core.state } pub fn computed(&self, key: ComputedKey) -> Rc { - { - let mut deps = self.computed_deps.borrow_mut(); - if !deps.contains(&key.id) { - deps.push(key.id); - } - } + self.record_dep(key.id); downcast(get(self.core, self.inner, key.id), self.inner, key.id) } + /// Record that this value read computed `id` (for a host that evaluated it itself). + #[cfg(feature = "host")] + pub fn track_computed(&self, id: usize) { + self.record_dep(id); + } + fn record_dep(&self, id: usize) { + let mut deps = self.computed_deps.borrow_mut(); + if !deps.contains(&id) { + deps.push(id); + } + } } #[derive(Default)] @@ -145,7 +158,11 @@ fn fresh(core: &Core, inner: &Inner, id: usize) -> Option at { + if core + .field_changed_at + .get(i) + .is_some_and(|&changed| changed > at) + { return None; } bits &= bits - 1; @@ -205,7 +222,23 @@ fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc f(¶ms), + #[cfg(feature = "host")] + ComputedSource::External(ext) => match inner.host() { + Some(host) => host.computed(*ext, ¶ms), + None => panic!("[machine] computed \"{}\" needs a host", def.name), + }, + }; + if inner.aborted() { + // The host failed mid-evaluation: do not cache what it returned. + let mut slot = slot_cell.borrow_mut(); + slot.evaluating = false; + slot.value = None; + slot.computed_deps = computed_deps.into_inner(); + std::mem::forget(reset); + return next; + } let mut slot = slot_cell.borrow_mut(); slot.evaluating = false; diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index e42f0d1..08b17b2 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -2,7 +2,7 @@ use std::any::Any; use std::collections::HashMap; use std::rc::Rc; -use crate::computed::{ComputedDef, ComputedKey, ComputedParams}; +use crate::computed::{ComputedDef, ComputedKey, ComputedParams, ComputedSource}; use crate::params::{ActionParams, GuardParams}; use crate::traits::{Context, EventEnum, Field, StateEnum, Types}; @@ -37,6 +37,10 @@ pub enum Guard { /// True iff any guard passes. Empty → false. Or(Vec>), Not(Box>), + /// Evaluated by the machine's [`Host`](crate::Host): a binding's callback (e.g. a JS + /// guard of a TS-authored machine), by the binding's own id. + #[cfg(feature = "host")] + External(u32), } impl Guard { @@ -69,6 +73,13 @@ pub enum Action { Fn(ActionFn), Named(Named>), OneOf(Vec>), + /// Run by the machine's [`Host`](crate::Host), with no internal borrow held. + #[cfg(feature = "host")] + External(u32), + /// A run of host actions, run by the [`Host`](crate::Host) in one call (in order, + /// stopping at the first failure), by the binding's own list id. + #[cfg(feature = "host")] + ExternalList(u32), } impl Action { @@ -124,6 +135,9 @@ impl Branch { pub enum Effect { Fn(EffectFn), Named(Named>), + /// Started by the machine's [`Host`](crate::Host), with no internal borrow held. + #[cfg(feature = "host")] + External(u32), } impl Effect { @@ -145,6 +159,9 @@ impl From<&'static str> for Effect { pub enum Delay { Ms(u32), Named(Named>), + /// Resolved by the machine's [`Host`](crate::Host). + #[cfg(feature = "host")] + External(u32), } impl From for Delay { @@ -215,7 +232,35 @@ impl TransitionBuilder { } /// A handler for one event (or one `after` delay): candidates in order, first passing guard wins. -pub(crate) type Entry = Vec>; +pub(crate) struct Entry { + candidates: Vec>, + /// The host evaluates the candidates' guards in one call, by the binding's own list id. + #[cfg(feature = "host")] + pub(crate) pick: Option, +} + +impl Default for Entry { + fn default() -> Self { + Self { + candidates: Vec::new(), + #[cfg(feature = "host")] + pick: None, + } + } +} + +impl std::ops::Deref for Entry { + type Target = Vec>; + fn deref(&self) -> &Self::Target { + &self.candidates + } +} + +impl std::ops::DerefMut for Entry { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.candidates + } +} pub(crate) struct StateNode { /// Indexed by event kind. @@ -230,7 +275,7 @@ pub(crate) struct StateNode { impl StateNode { fn new(kinds: usize) -> Self { Self { - on: (0..kinds).map(|_| Vec::new()).collect(), + on: (0..kinds).map(|_| Entry::default()).collect(), entry: Vec::new(), exit: Vec::new(), effects: Vec::new(), @@ -290,7 +335,9 @@ impl StateBuilder { build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, ) -> Self { let t = build(TransitionBuilder(empty_transition())); - self.0.after.push((delay.into(), vec![t.0])); + let mut entry = Entry::default(); + entry.push(t.0); + self.0.after.push((delay.into(), entry)); self } /// Add a fallthrough candidate to the most recent `after`. @@ -311,6 +358,23 @@ impl StateBuilder { self.0.tags.push(tag); self } + /// The host evaluates the guards of the handler for `kind` in one call: its list `list`. + #[cfg(feature = "host")] + pub fn pick(mut self, kind: ::Kind, list: u32) -> Self { + self.0.on[::kind_index(kind)].pick = Some(list); + self + } + /// The host evaluates the guards of the most recent `after` in one call. + #[cfg(feature = "host")] + pub fn after_pick(mut self, list: u32) -> Self { + self.0 + .after + .last_mut() + .expect("after_pick() needs a preceding after()") + .1 + .pick = Some(list); + self + } } fn empty_transition() -> Transition { @@ -353,15 +417,38 @@ impl Clone for Config { impl Config { pub fn builder(initial: T::State, context: T::Context) -> ConfigBuilder { - let kinds = ::KIND_NAMES.len(); + Self::builder_with_counts( + initial, + context, + ::NAMES.len(), + ::KIND_NAMES.len(), + ) + } + + /// A builder for a machine whose states and event types are known only at runtime + /// (a binding building a TS-authored machine): `states` and `kinds` are their counts. + #[cfg(feature = "host")] + pub fn builder_sized( + initial: T::State, + context: T::Context, + states: usize, + kinds: usize, + ) -> ConfigBuilder { + Self::builder_with_counts(initial, context, states, kinds) + } + + fn builder_with_counts( + initial: T::State, + context: T::Context, + states: usize, + kinds: usize, + ) -> ConfigBuilder { ConfigBuilder { inner: ConfigInner { initial, context, - states: (0..::NAMES.len()) - .map(|_| StateNode::new(kinds)) - .collect(), - on_any: (0..kinds).map(|_| Vec::new()).collect(), + states: (0..states).map(|_| StateNode::new(kinds)).collect(), + on_any: (0..kinds).map(|_| Entry::default()).collect(), computed: Vec::new(), watch: Vec::new(), guards: HashMap::new(), @@ -369,6 +456,7 @@ impl Config { actions: HashMap::new(), effects: HashMap::new(), delays: HashMap::new(), + kinds, } } @@ -407,6 +495,8 @@ pub struct ConfigBuilder { actions: HashMap<&'static str, ActionFn>, effects: HashMap<&'static str, EffectFn>, delays: HashMap<&'static str, DelayFn>, + /// Number of event types (each state's `on` table has one slot per type). + kinds: usize, } impl ConfigBuilder { @@ -416,7 +506,7 @@ impl ConfigBuilder { build: impl FnOnce(StateBuilder) -> StateBuilder, ) -> &mut Self { let index = state.index(); - let kinds = ::KIND_NAMES.len(); + let kinds = self.kinds; let node = std::mem::replace(&mut self.inner.states[index], StateNode::new(kinds)); self.inner.states[index] = build(StateBuilder(node)).0; self @@ -433,6 +523,13 @@ impl ConfigBuilder { self } + /// The host evaluates the guards of the any-state handler for `kind` in one call. + #[cfg(feature = "host")] + pub fn pick_any(&mut self, kind: ::Kind, list: u32) -> &mut Self { + self.inner.on_any[::kind_index(kind)].pick = Some(list); + self + } + pub fn guard( &mut self, name: &'static str, @@ -479,12 +576,45 @@ impl ConfigBuilder { let id = self.inner.computed.len(); self.inner.computed.push(ComputedDef { name, - eval: Rc::new(move |p: &ComputedParams<'_, T>| Rc::new(f(p)) as Rc), + eval: ComputedSource::Fn(Rc::new(move |p: &ComputedParams<'_, T>| { + Rc::new(f(p)) as Rc + })), eq: eq_any::, }); ComputedKey::new(id) } + /// A computed value the machine's [`Host`](crate::Host) evaluates (e.g. a JS function + /// of a TS-authored machine). `eq` decides whether a new value changed. Returns its id. + #[cfg(feature = "host")] + pub fn computed_external( + &mut self, + name: &'static str, + eq: fn(&dyn Any, &dyn Any) -> bool, + ) -> usize { + let id = self.inner.computed.len(); + self.inner.computed.push(ComputedDef { + name, + eval: ComputedSource::External(id as u32), + eq, + }); + id + } + + /// Data-reaction on computed value `id` (for computed values defined by id). + #[cfg(feature = "host")] + pub fn watch_computed_id( + &mut self, + id: usize, + actions: impl IntoIterator>, + ) -> &mut Self { + self.inner.watch.push(Watch { + source: WatchSource::Computed(id), + actions: actions.into_iter().collect(), + }); + self + } + /// Data-reaction on a context field: its actions run (deferred) whenever the field /// changes, in any state, while the machine runs. pub fn watch( @@ -515,12 +645,12 @@ impl ConfigBuilder { /// Resolve every named reference. A name with no implementation stays unresolved and /// fails when it runs: a panic in debug builds, a warning + no-op in release builds. pub fn build(&mut self) -> Config { - let kinds = ::KIND_NAMES.len(); + let kinds = self.kinds; let empty = ConfigInner { initial: self.inner.initial, context: self.inner.context.clone(), states: Vec::new(), - on_any: (0..kinds).map(|_| Vec::new()).collect(), + on_any: (0..kinds).map(|_| Entry::default()).collect(), computed: Vec::new(), watch: Vec::new(), guards: HashMap::new(), @@ -569,7 +699,7 @@ struct Registry<'r, T: Types> { impl Registry<'_, T> { fn entry(&self, entry: &mut Entry) { - for t in entry { + for t in entry.iter_mut() { if let Some(g) = &mut t.guard { self.guard(g); } @@ -579,6 +709,8 @@ impl Registry<'_, T> { fn guard(&self, guard: &mut Guard) { match guard { Guard::Fn(_) => {} + #[cfg(feature = "host")] + Guard::External(_) => {} Guard::Named(n) => n.imp = self.guards.get(n.name).cloned(), Guard::And(gs) | Guard::Or(gs) => gs.iter_mut().for_each(|g| self.guard(g)), Guard::Not(g) => self.guard(g), @@ -588,6 +720,8 @@ impl Registry<'_, T> { for a in actions { match a { Action::Fn(_) => {} + #[cfg(feature = "host")] + Action::External(_) | Action::ExternalList(_) => {} Action::Named(n) => n.imp = self.actions.get(n.name).cloned(), Action::OneOf(branches) => { for b in branches { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1800700..1a66299 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,9 +1,10 @@ -//! `dunky-core` — the Rust port of `@dunky.dev/state-machine`. +//! `dunky-core` — the engine of `@dunky.dev/state-machine`, and the crate to write +//! machines in Rust. //! -//! The behavior contract is `packages/core/SPEC.md`; this crate implements the same -//! engine (flat states, run-to-completion, guards, actions, effects, `after` timers, -//! computed data, watchers, selections, connector, composition, store) for Rust hosts and -//! for the wasm / native bindings. +//! The behavior contract is `packages/core/SPEC.md`; what is specific to this crate is +//! `crates/core/SPEC.md`. The engine (flat states, run-to-completion, guards, actions, +//! effects, `after` timers, computed data, watchers, selections, connector, composition, +//! store) serves Rust programs, and machines written in TypeScript through `dunky-wasm`. //! //! Rust-specific shape: //! - **Typed**: states, events and context are Rust types (`#[derive(State, Event, Context)]`). @@ -38,6 +39,8 @@ pub use config::{ TransitionBuilder, }; pub use connector::{ConnectSnapshot, Connector, Reaction}; +#[cfg(feature = "host")] +pub use machine::Host; pub use machine::{Changes, Machine, Sender}; pub use params::{ActionParams, GuardParams, View}; pub use selection::Selection; diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs index 5ab4e93..818d08e 100644 --- a/crates/core/src/machine.rs +++ b/crates/core/src/machine.rs @@ -1,31 +1,64 @@ //! The live service: run-to-completion queue, transition resolution, lifecycle. //! //! Borrow discipline (the Rust shape of the TS engine's re-entrancy rules): -//! - Actions, guards, effects, delays and computed definitions run while the core is +//! - Rust actions, guards, effects, delays and computed definitions run while the core is //! borrowed; they get params instead of the machine handle. +//! - Host callbacks (`External` guards/actions/effects/delays/computed — e.g. the JS +//! functions of a TS-authored machine) run with no MUTABLE borrow held: they may read +//! the machine, mark context changes ([`Machine::mark_changed`]) and send. //! - Observers (subscribe, selections, lifecycle listeners, effect cleanups) run with NO //! borrow held, so they may read the machine, send to it, or patch it. Sends made while //! a flush is in progress are queued and run after the current item, never interleaved. use std::any::Any; -use std::cell::{Cell, Ref, RefCell}; +use std::cell::{Cell, OnceCell, Ref, RefCell}; use std::collections::VecDeque; use std::rc::{Rc, Weak}; use crate::broadcast::{Broadcast, Subscription}; -use crate::computed::{self, ComputedKey, ComputedRuntime}; +use crate::computed::{self, ComputedKey, ComputedParams, ComputedRuntime}; use crate::config::{ - Action, Config, Delay, Effect, Entry, Guard, Transition, WatchSource, missing, + Action, Cleanup, Config, Delay, Effect, Entry, Guard, Transition, WatchSource, missing, }; use crate::params::{ActionParams, GuardParams, View}; use crate::selection::Selection; use crate::timers::{Command, TimerId}; use crate::traits::{Context, EventEnum, StateEnum, Types}; -/// Dev-only runaway guard for one flush. A real chain is a handful of items; thousands -/// means a feedback loop (a watcher writing what it watches, actions sending in a cycle). +/// Runaway guard for one flush. A real chain is a handful of items; thousands means a +/// feedback loop (a watcher writing what it watches, actions sending in a cycle). const MAX_FLUSH: u32 = 10_000; +/// The binding that runs a machine's `External` parts — e.g. the JS functions of a +/// TS-authored machine — and hears about its changes. On a failure (a JS exception) the +/// host keeps the error, returns a placeholder, and raises the machine's halt flag +/// ([`Machine::halt_flag`]); the engine then stops the current step and the binding +/// rethrows. +/// +/// Part of the binding API (cargo feature `host`). +#[cfg_attr(not(feature = "host"), allow(dead_code))] +pub trait Host { + fn guard(&self, id: u32, event: Option<&T::Event>) -> bool; + fn action(&self, id: u32, event: Option<&T::Event>); + /// Start an effect; return its cleanup, if any. + fn effect(&self, id: u32, event: Option<&T::Event>) -> Option; + fn delay(&self, id: u32, event: Option<&T::Event>) -> u32; + /// Evaluate computed value `id`, reporting what it read through `params`. + fn computed(&self, id: u32, params: &ComputedParams<'_, T>) -> Rc; + /// Evaluate the guards of candidate list `list` in order: the index of the first that + /// passes (a candidate without a guard passes). + fn pick(&self, list: u32, event: Option<&T::Event>) -> Option; + /// Run the actions of list `list` in order, stopping at the first failure. + fn actions(&self, list: u32, event: Option<&T::Event>); + /// The machine changed (once per effective change, like a coarse subscriber), with + /// no borrow held. `state` is the current state; `changes` is what changed since the + /// previous notify. A host may hold a notification back to deliver it with its next + /// callback, as long as it delivers it first and no later than [`Host::settle`]. + fn notify(&self, state: T::State, changes: Changes); + /// A transition finished: deliver any notification held back. + fn settle(&self); +} + pub(crate) enum Item { Event(T::Event), After { @@ -53,17 +86,21 @@ pub(crate) struct Core { pub(crate) running: bool, /// Bumped on every state entry; a timer scheduled under another generation is stale. entry_counter: u64, - state_cleanups: Vec, + state_cleanups: Vec, timers: Vec>, next_timer_id: u32, commands: Vec, pub(crate) computed: ComputedRuntime, /// Machine-wide change counter; fields and the state are stamped with it when they change. pub(crate) tick: u64, + /// Per field, the tick it last changed at; grown on first write, so a machine pays + /// only for the fields it writes. pub(crate) field_changed_at: Vec, pub(crate) state_changed_at: u64, /// Effective changes made inside the current borrow; drained into notifications. pending_notifies: u32, + /// What those changes touched, for the host's next notify. + notify_changes: Changes, /// Changes since the last `take_changes()`, for bindings. changed_fields: u64, changed_state: bool, @@ -73,19 +110,34 @@ impl Core { /// Apply `patch`; return the mask of the fields that actually changed. pub(crate) fn patch(&mut self, patch: ::Patch) -> u64 { let mask = self.ctx.apply(patch); + self.mark_fields(mask); + mask + } + + /// Stamp the fields in `mask` as changed now and owe one notification. + fn mark_fields(&mut self, mask: u64) { if mask == 0 { - return 0; + return; } + self.stamp_fields(mask); + self.changed_fields |= mask; + self.notify_changes.fields |= mask; + self.pending_notifies += 1; + } + + /// Stamp the fields in `mask` as changed now. + fn stamp_fields(&mut self, mask: u64) { self.tick += 1; + let top = 64 - mask.leading_zeros() as usize; + if self.field_changed_at.len() < top { + self.field_changed_at.resize(top, 0); + } let mut bits = mask; while bits != 0 { let i = bits.trailing_zeros() as usize; self.field_changed_at[i] = self.tick; bits &= bits - 1; } - self.changed_fields |= mask; - self.pending_notifies += 1; - mask } fn set_state(&mut self, next: T::State) { @@ -93,6 +145,7 @@ impl Core { self.tick += 1; self.state_changed_at = self.tick; self.changed_state = true; + self.notify_changes.state = true; self.pending_notifies += 1; } } @@ -102,11 +155,21 @@ pub(crate) struct Inner { pub(crate) core: RefCell>, pub(crate) queue: RefCell>>, flushing: Cell, - pub(crate) bus: Rc, + // Created on first use: a machine nobody subscribes to pays nothing. + bus: OnceCell>, /// Per watch: the change stamp a computed watch last saw (unused by field watches). watch_stamps: RefCell>, - lifecycle: Rc, - stop_listeners: Rc, + lifecycle: OnceCell>, + stop_listeners: OnceCell>, + host: OnceCell>>, + /// Context changes a host marked while the core was borrowed; applied at the next + /// safe point. + external_mask: Cell, + /// The engine's own failure (a feedback loop) when a host is attached. + failure: RefCell>, + /// The current step must stop: a host callback failed (the host raises it) or the + /// engine did. Shared with the host, so a check is a load. + halt: Rc>, pub(crate) weak_self: Weak>, } @@ -159,9 +222,8 @@ impl Changes { impl Machine { /// Build a stopped service from `config`, with its own copy of the seed context. pub fn new(config: &Config) -> Self { - let fields = ::FIELDS.len(); assert!( - fields <= 64, + ::FIELDS.len() <= 64, "[machine] a context supports at most 64 fields" ); let core = Core { @@ -175,9 +237,10 @@ impl Machine { commands: Vec::new(), computed: ComputedRuntime::new(config.0.computed.len()), tick: 0, - field_changed_at: vec![0; fields], + field_changed_at: Vec::new(), state_changed_at: 0, pending_notifies: 0, + notify_changes: Changes::default(), changed_fields: 0, changed_state: false, }; @@ -186,10 +249,14 @@ impl Machine { core: RefCell::new(core), queue: RefCell::new(VecDeque::new()), flushing: Cell::new(false), - bus: Broadcast::new(), + bus: OnceCell::new(), watch_stamps: RefCell::new(vec![0; config.0.watch.len()]), - lifecycle: Broadcast::new(), - stop_listeners: Broadcast::new(), + lifecycle: OnceCell::new(), + stop_listeners: OnceCell::new(), + host: OnceCell::new(), + external_mask: Cell::new(0), + failure: RefCell::new(None), + halt: Rc::new(Cell::new(false)), weak_self: weak.clone(), })) } @@ -201,6 +268,13 @@ impl Machine { m } + /// Attach the host that runs this machine's `External` parts. A machine has one host + /// for life: returns false when it already has one. + #[cfg(feature = "host")] + pub fn set_host(&self, host: Rc>) -> bool { + self.0.host.set(host).is_ok() + } + pub fn config(&self) -> &Config { &self.0.config } @@ -246,28 +320,76 @@ impl Machine { /// Dispatch an event. It runs to completion before the next queued item. pub fn send(&self, event: T::Event) { - self.0.queue.borrow_mut().push_back(Item::Event(event)); - self.0.flush(); + let inner = &*self.0; + if inner.flushing.get() || !inner.queue.borrow().is_empty() { + inner.queue.borrow_mut().push_back(Item::Event(event)); + inner.flush(); + return; + } + // Idle: run it now, then whatever it queued — no trip through the queue. + inner.flushing.set(true); + let reset = FlushReset(&inner.flushing, false); + inner.process(Item::Event(event)); + drop(reset); + inner.flush(); } /// Shallow-merge `patch` into the context (bridges use this to refresh prop-seeded /// context). Observers are notified only if a field actually changed. pub fn set_context(&self, patch: ::Patch) { self.0.with_flush(|inner| { - { - let mut core = inner.core.borrow_mut(); - let fields = core.patch(patch); - if fields != 0 { - inner.detect_watched(&core, fields); - } + let fields = inner.core.borrow_mut().patch(patch); + if fields != 0 { + inner.detect_watched(&inner.core.borrow(), fields); + } + inner.drain_notifies(); + }); + } + + /// A host changed fields of a context it owns (a TS machine's JS context): stamp them + /// and run the watchers — right away when possible, else at the next safe point. The + /// host notifies its own observers of the write. + #[cfg(feature = "host")] + pub fn mark_changed(&self, mask: u64) { + if mask == 0 { + return; + } + let inner = &*self.0; + let applied = match inner.core.try_borrow_mut() { + Ok(mut core) => { + core.stamp_fields(mask); + true + } + Err(_) => false, + }; + if !applied { + inner.external_mask.set(inner.external_mask.get() | mask); + return; + } + inner.with_flush(|inner| { + if let Ok(core) = inner.core.try_borrow() { + inner.detect_watched(&core, mask); } inner.drain_notifies(); }); } + /// The engine's own failure since the last call (a feedback loop), when a host is attached. + #[cfg(feature = "host")] + pub fn take_failure(&self) -> Option { + self.0.failure.borrow_mut().take() + } + + /// The flag that stops the current step. A host raises it when a callback fails and + /// lowers it once the binding has rethrown, before the next call. + #[cfg(feature = "host")] + pub fn halt_flag(&self) -> Rc> { + self.0.halt.clone() + } + /// Coarse subscription: fires on any later change (state or context), never on subscribe. pub fn subscribe(&self, listener: impl Fn() + 'static) -> Subscription { - self.0.bus.add(Rc::new(listener)) + self.0.bus().add(Rc::new(listener)) } /// A value-deduped view of anything the machine exposes. @@ -302,8 +424,10 @@ impl Machine { inner.start_effects(state, None); true }); - if started { - inner.with_flush(|inner| inner.lifecycle.notify()); + if started && !inner.aborted() { + if let Some(listeners) = inner.lifecycle.get() { + inner.with_flush(|_| listeners.notify()); + } } } @@ -323,15 +447,21 @@ impl Machine { inner.stop_effects(); true }); - if stopped { - inner.with_flush(|inner| inner.stop_listeners.notify()); + if stopped && !inner.aborted() { + if let Some(listeners) = inner.stop_listeners.get() { + inner.with_flush(|_| listeners.notify()); + } } } /// Fires on every `start()` — immediately if already running. pub fn on_start(&self, listener: impl Fn() + 'static) -> Subscription { let listener: Rc = Rc::new(listener); - let sub = self.0.lifecycle.add(listener.clone()); + let sub = self + .0 + .lifecycle + .get_or_init(Broadcast::new) + .add(listener.clone()); if self.is_running() { listener(); } @@ -340,7 +470,10 @@ impl Machine { /// Fires on every `stop()`. pub fn on_stop(&self, listener: impl Fn() + 'static) -> Subscription { - self.0.stop_listeners.add(Rc::new(listener)) + self.0 + .stop_listeners + .get_or_init(Broadcast::new) + .add(Rc::new(listener)) } /// Host callback: the timer `id` (from a [`Command::StartTimer`]) came due. @@ -391,11 +524,24 @@ impl Machine { /// Number of coarse subscribers (diagnostics). pub fn subscriber_count(&self) -> usize { - self.0.bus.len() + self.0.bus.get().map_or(0, |bus| bus.len()) } } impl Inner { + pub(crate) fn bus(&self) -> &Rc { + self.bus.get_or_init(Broadcast::new) + } + + pub(crate) fn host(&self) -> Option<&Rc>> { + self.host.get() + } + + /// The current step must stop: a host callback failed, or the engine itself did. + pub(crate) fn aborted(&self) -> bool { + self.halt.get() + } + /// Run `f` as part of a flush: re-entrant sends queue instead of nesting, and the queue /// drains after `f` unless an outer flush owns it. fn with_flush(&self, f: impl FnOnce(&Self) -> R) -> R { @@ -417,16 +563,25 @@ impl Inner { let _reset = FlushReset(&self.flushing, false); let mut ticks = 0u32; loop { + if self.aborted() { + break; // the binding rethrows; what is still queued stays queued + } let item = self.queue.borrow_mut().pop_front(); let Some(item) = item else { break }; - if cfg!(debug_assertions) { - ticks += 1; - if ticks > MAX_FLUSH { - self.queue.borrow_mut().clear(); - panic!( - "[machine] one flush exceeded {MAX_FLUSH} steps — feedback loop \ - (e.g. a watcher writing the field it watches, or actions sending in a cycle)" - ); + ticks += 1; + if ticks > MAX_FLUSH { + self.queue.borrow_mut().clear(); + let msg = format!( + "[machine] one flush exceeded {MAX_FLUSH} steps — feedback loop \ + (e.g. a watcher writing the field it watches, or actions sending in a cycle)" + ); + if self.host.get().is_some() { + *self.failure.borrow_mut() = Some(msg); + self.halt.set(true); + break; + } + if cfg!(debug_assertions) { + panic!("{msg}"); } } self.process(item); @@ -468,6 +623,7 @@ impl Inner { } } } + self.absorb_external(); } /// The first candidate whose guard passes. @@ -476,40 +632,81 @@ impl Inner { entry: &'c Entry, event: Option<&T::Event>, ) -> Option<&'c Transition> { - let core = self.core.borrow(); - let params = GuardParams { - core: &core, - inner: self, - event, + // The host evaluates the whole list in one call, with no borrow held. + #[cfg(feature = "host")] + if let Some(list) = entry.pick + && let Some(host) = self.host() + { + let picked = host.pick(list, event); + self.absorb_external(); + return if self.aborted() { + None + } else { + picked.and_then(|i| entry.get(i)) + }; + } + let picked = { + let core = self.core.borrow(); + let params = GuardParams { + core: &core, + inner: self, + event, + }; + entry.iter().find(|t| { + t.guard + .as_ref() + .is_none_or(|g| eval_guard(g, ¶ms) && !self.aborted()) + || self.aborted() + }) }; - entry - .iter() - .find(|t| t.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) + if self.aborted() { None } else { picked } } fn apply(&self, t: &Transition, event: Option<&T::Event>) { - let (cur, running) = { - let core = self.core.borrow(); - (core.state, core.running) - }; + let cur = self.core.borrow().state; let next = t.target.unwrap_or(cur); + self.apply_steps(t, event, cur, next); + if next != cur + && let Some(host) = self.host() + { + host.settle(); + } + } + + fn apply_steps( + &self, + t: &Transition, + event: Option<&T::Event>, + cur: T::State, + next: T::State, + ) { + let running = self.core.borrow().running; let leaving = next != cur; let states = &self.config.0.states; if leaving { if running { self.stop_effects(); + if self.aborted() { + return; + } } self.run_actions(&states[cur.index()].exit, event); + if self.aborted() { + return; + } } self.run_actions(&t.actions, event); + if self.aborted() { + return; + } if leaving { - { - let mut core = self.core.borrow_mut(); - core.set_state(next); - self.detect_watched(&core, 0); - } + self.core.borrow_mut().set_state(next); + self.detect_watched(&self.core.borrow(), 0); self.drain_notifies(); self.run_actions(&states[next.index()].entry, event); + if self.aborted() { + return; + } if self.core.borrow().running { self.start_effects(next, event); } @@ -519,6 +716,9 @@ impl Inner { pub(crate) fn run_actions(&self, actions: &[Action], event: Option<&T::Event>) { for action in actions { self.run_action(action, event); + if self.aborted() { + return; + } } } @@ -541,10 +741,34 @@ impl Inner { .iter() .find(|b| b.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) }; + if self.aborted() { + return; + } if let Some(branch) = picked { self.run_actions(&branch.actions, event); } } + #[cfg(feature = "host")] + Action::External(id) => { + // No borrow held: the host may read computed values, mark changes, send. + if let Some(host) = self.host() { + host.action(*id, event); + } else { + missing("action host for", &id.to_string()); + } + self.absorb_external(); + self.drain_notifies(); + } + #[cfg(feature = "host")] + Action::ExternalList(list) => { + if let Some(host) = self.host() { + host.actions(*list, event); + } else { + missing("action host for list", &list.to_string()); + } + self.absorb_external(); + self.drain_notifies(); + } } } @@ -561,81 +785,142 @@ impl Inner { self.drain_notifies(); } + /// Apply context changes a host marked while the core was borrowed. + fn absorb_external(&self) { + let mask = self.external_mask.replace(0); + if mask == 0 { + return; + } + self.core.borrow_mut().stamp_fields(mask); + self.detect_watched(&self.core.borrow(), mask); + } + /// Deliver the notifications owed by the changes made in the last borrow — once per /// effective change, with no borrow held so observers can read and send. pub(crate) fn drain_notifies(&self) { - let n = std::mem::take(&mut self.core.borrow_mut().pending_notifies); + let (n, state, mut changes) = { + let mut core = self.core.borrow_mut(); + ( + std::mem::take(&mut core.pending_notifies), + core.state, + std::mem::take(&mut core.notify_changes), + ) + }; + if n == 0 { + return; + } + let host = self.host.get(); + let bus = self.bus.get(); for _ in 0..n { - self.bus.notify(); + if let Some(bus) = bus { + bus.notify(); + } + if let Some(host) = &host { + host.notify(state, std::mem::take(&mut changes)); + } } } fn start_effects(&self, state: T::State, event: Option<&T::Event>) { let node = &self.config.0.states[state.index()]; - { + let generation = { let mut core = self.core.borrow_mut(); core.entry_counter += 1; - let generation = core.entry_counter; - for (index, (delay, _)) in node.after.iter().enumerate() { - let ms = match delay { - Delay::Ms(ms) => *ms, - Delay::Named(n) => match &n.imp { - Some(f) => f(&GuardParams { - core: &core, - inner: self, - event, - }), - None => { - missing("delay", n.name); - 0 - } - }, - }; - let id = core.next_timer_id; - core.next_timer_id = core.next_timer_id.wrapping_add(1).max(1); - core.timers.push(TimerRecord { - id, - state, - index, - event: event.cloned(), - generation, - }); - core.commands.push(Command::StartTimer { id, ms }); + core.entry_counter + }; + for (index, (delay, _)) in node.after.iter().enumerate() { + let ms = match delay { + Delay::Ms(ms) => *ms, + Delay::Named(n) => match &n.imp { + Some(f) => f(&GuardParams { + core: &self.core.borrow(), + inner: self, + event, + }), + None => { + missing("delay", n.name); + 0 + } + }, + #[cfg(feature = "host")] + Delay::External(id) => match self.host() { + Some(host) => host.delay(*id, event), + None => { + missing("delay host for", &id.to_string()); + 0 + } + }, + }; + if self.aborted() { + return; } + let mut core = self.core.borrow_mut(); + let id = core.next_timer_id; + core.next_timer_id = core.next_timer_id.wrapping_add(1).max(1); + core.timers.push(TimerRecord { + id, + state, + index, + event: event.cloned(), + generation, + }); + core.commands.push(Command::StartTimer { id, ms }); } for effect in &node.effects { - let f = match effect { - Effect::Fn(f) => f, + let cleanup = match effect { + Effect::Fn(f) => self.call_effect(f, event), Effect::Named(n) => match &n.imp { - Some(f) => f, + Some(f) => self.call_effect(f, event), None => { missing("effect", n.name); continue; } }, - }; - let cleanup = { - let mut core = self.core.borrow_mut(); - let mut params = ActionParams { - core: &mut core, - inner: self, - event, - }; - f(&mut params) + // No borrow held: the host may read computed values, mark changes, send. + #[cfg(feature = "host")] + Effect::External(id) => match self.host() { + Some(host) => host.effect(*id, event), + None => { + missing("effect host for", &id.to_string()); + None + } + }, }; if let Some(cleanup) = cleanup { self.core.borrow_mut().state_cleanups.push(cleanup); } + self.absorb_external(); self.drain_notifies(); + if self.aborted() { + return; + } } } - /// Cancel the state's timers and run its effect cleanups. A panicking cleanup must not - /// leak the others: finish the pass, then resume the first panic. + fn call_effect( + &self, + f: &crate::config::EffectFn, + event: Option<&T::Event>, + ) -> Option { + let mut core = self.core.borrow_mut(); + let mut params = ActionParams { + core: &mut core, + inner: self, + event, + }; + f(&mut params) + } + + /// Cancel the state's timers and run its effect cleanups. A failing cleanup must not + /// leak the others: finish the pass, then report the first failure (a Rust panic is + /// resumed; a host failure is left for the binding to rethrow). fn stop_effects(&self) { let cleanups = { let mut core = self.core.borrow_mut(); let core = &mut *core; + if core.timers.is_empty() && core.state_cleanups.is_empty() { + return; + } for t in core.timers.drain(..) { core.commands.push(Command::CancelTimer { id: t.id }); } @@ -657,12 +942,14 @@ impl Inner { /// seed: a write's change mask names the fields it changed. fn start_watchers(&self) { let core = self.core.borrow(); - let mut stamps = self.watch_stamps.borrow_mut(); - for (index, watch) in self.config.0.watch.iter().enumerate() { - if let WatchSource::Computed(id) = watch.source { - stamps[index] = computed::changed_at(&core, self, id); - } + let mut stamps = Vec::with_capacity(self.config.0.watch.len()); + for watch in &self.config.0.watch { + stamps.push(match watch.source { + WatchSource::Computed(id) => computed::changed_at(&core, self, id), + WatchSource::Field(_) => 0, + }); } + *self.watch_stamps.borrow_mut() = stamps; } /// Watchers observe a change as it happens, like the TS selections they mirror: each @@ -671,7 +958,7 @@ impl Inner { /// deferred). `fields` is the write's change mask; a state switch passes 0, since /// only a computed value can observe it. pub(crate) fn detect_watched(&self, core: &Core, fields: u64) { - if !core.running { + if !core.running || self.config.0.watch.is_empty() { return; } for (index, watch) in self.config.0.watch.iter().enumerate() { @@ -710,6 +997,14 @@ pub(crate) fn eval_guard(guard: &Guard, params: &GuardParams<'_, T> Guard::And(gs) => gs.iter().all(|g| eval_guard(g, params)), Guard::Or(gs) => gs.iter().any(|g| eval_guard(g, params)), Guard::Not(g) => !eval_guard(g, params), + #[cfg(feature = "host")] + Guard::External(id) => match params.inner.host() { + Some(host) => host.guard(*id, params.event), + None => { + missing("guard host for", &id.to_string()); + false + } + }, } } diff --git a/crates/core/src/selection.rs b/crates/core/src/selection.rs index a5d246a..0232b09 100644 --- a/crates/core/src/selection.rs +++ b/crates/core/src/selection.rs @@ -63,7 +63,7 @@ impl Selection { let prev: RefCell>> = RefCell::new(seed.map(Rc::new)); let selector = self.selector.clone(); let weak = Rc::downgrade(inner); - inner.bus.add(Rc::new(move || { + inner.bus().add(Rc::new(move || { let Some(inner) = weak.upgrade() else { return }; let next = { let core = inner.core.borrow(); From c0825d34e3905db3e7c1682b18d0b5a1450a3ee0 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:09:32 +0200 Subject: [PATCH 10/28] feat(core): run TS machines on the Rust engine, Rust machines via fromWasm @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 --- .changeset/export-broadcast-selection.md | 27 - .changeset/rust-engine.md | 39 + .changeset/use-machine-accepts-machine.md | 12 +- .changeset/wasm-adapter-package.md | 32 - .github/actions/wasm-setup/action.yml | 2 +- .github/workflows/ci-checks.yml | 6 +- .github/workflows/deploy-benchmark.yml | 2 + .github/workflows/release.yml | 2 + .gitignore | 4 +- .oxlintrc.json | 7 +- Cargo.lock | 27 +- Cargo.toml | 14 +- benchmark/package.json | 1 - benchmark/rust.ts | 98 +++ benchmark/rust/Cargo.toml | 17 + {crates/demo => benchmark/rust}/src/cell.rs | 0 {crates/demo => benchmark/rust}/src/guards.rs | 0 .../demo => benchmark/rust}/src/highlight.rs | 0 benchmark/rust/src/lib.rs | 101 +++ {crates/demo => benchmark/rust}/src/list.rs | 0 .../demo => benchmark/rust}/src/pingpong.rs | 0 benchmark/tests/rendering/bench.tsx | 12 +- benchmark/tests/wasm.ts | 60 +- benchmark/wasm-built.ts | 8 +- crates/core-wasm/Cargo.toml | 14 + crates/core-wasm/src/lib.rs | 48 ++ crates/demo-wasm/Cargo.toml | 28 - crates/demo-wasm/src/lib.rs | 103 --- crates/demo/Cargo.toml | 14 - crates/demo/src/lib.rs | 30 - crates/uniffi/Cargo.lock | 139 +--- crates/uniffi/Cargo.toml | 12 +- crates/uniffi/src/lib.rs | 53 +- crates/wasm/Cargo.toml | 9 +- crates/wasm/README.md | 44 ++ crates/wasm/SPEC.md | 140 ++++ crates/wasm/src/bridge.rs | 212 ++++++ crates/wasm/src/lib.rs | 356 +++++---- crates/wasm/src/runtime.rs | 603 ++++++++++++++++ knip.config.ts | 4 - packages/core/src/actions.ts | 56 +- packages/core/src/compile.ts | 257 +++++++ packages/core/src/computed.ts | 120 ---- packages/core/src/constants.ts | 9 - packages/core/src/from-wasm.ts | 146 ++++ packages/core/src/index.ts | 11 +- packages/core/src/machine.ts | 678 +++++++++++------- packages/core/src/transitions.ts | 57 -- packages/core/src/wasm.ts | 21 + packages/core/tests/from-wasm.test.ts | 107 +++ packages/demo-wasm/package.json | 20 - packages/demo-wasm/src/index.ts | 109 --- packages/demo-wasm/src/node.ts | 9 - packages/demo-wasm/tests/parity.test.ts | 131 ---- packages/demo-wasm/tsconfig.json | 5 - packages/native-rust/.gitignore | 7 - packages/native-rust/README.md | 82 --- packages/native-rust/package.json | 43 -- packages/native-rust/src/index.ts | 128 ---- packages/native-rust/tsconfig.json | 7 - packages/native-rust/ubrn.config.yaml | 18 - packages/wasm/LICENSE | 21 - packages/wasm/README.md | 54 -- packages/wasm/package.json | 39 - packages/wasm/src/index.ts | 285 -------- packages/wasm/tests/adapter.test.ts | 190 ----- packages/wasm/tsconfig.json | 5 - pnpm-lock.yaml | 118 --- sandbox/native/app.tsx | 25 +- sandbox/native/command-palette.tsx | 10 +- sandbox/native/metro.config.js | 7 +- sandbox/native/package.json | 1 - sandbox/native/rust-source.ts | 17 - sandbox/native/tsconfig.json | 2 +- sandbox/opentui/package.json | 3 +- sandbox/opentui/src/app.tsx | 7 +- sandbox/opentui/src/index.tsx | 20 +- sandbox/react/package.json | 1 - sandbox/react/src/app.tsx | 11 +- sandbox/react/src/main.tsx | 21 +- sandbox/react/vite.config.ts | 3 - sandbox/shared/package.json | 4 +- sandbox/shared/rust/Cargo.toml | 24 + .../shared/rust}/src/dialog.rs | 0 sandbox/shared/rust/src/lib.rs | 49 ++ .../shared/rust}/src/palette.rs | 0 sandbox/shared/src/rust-node.ts | 9 + sandbox/shared/src/rust.ts | 53 ++ sandbox/shared/tests/rust.test.ts | 94 +++ sandbox/solid/package.json | 1 - sandbox/solid/src/app.tsx | 11 +- sandbox/solid/src/main.tsx | 21 +- sandbox/solid/vite.config.ts | 3 - scripts/build-wasm.sh | 57 +- tsconfig.build.json | 2 - tsconfig.json | 1 - tsdown.config.ts | 1 - vitest.config.ts | 2 +- 98 files changed, 2935 insertions(+), 2538 deletions(-) delete mode 100644 .changeset/export-broadcast-selection.md create mode 100644 .changeset/rust-engine.md delete mode 100644 .changeset/wasm-adapter-package.md create mode 100644 benchmark/rust.ts create mode 100644 benchmark/rust/Cargo.toml rename {crates/demo => benchmark/rust}/src/cell.rs (100%) rename {crates/demo => benchmark/rust}/src/guards.rs (100%) rename {crates/demo => benchmark/rust}/src/highlight.rs (100%) create mode 100644 benchmark/rust/src/lib.rs rename {crates/demo => benchmark/rust}/src/list.rs (100%) rename {crates/demo => benchmark/rust}/src/pingpong.rs (100%) create mode 100644 crates/core-wasm/Cargo.toml create mode 100644 crates/core-wasm/src/lib.rs delete mode 100644 crates/demo-wasm/Cargo.toml delete mode 100644 crates/demo-wasm/src/lib.rs delete mode 100644 crates/demo/Cargo.toml delete mode 100644 crates/demo/src/lib.rs create mode 100644 crates/wasm/README.md create mode 100644 crates/wasm/SPEC.md create mode 100644 crates/wasm/src/bridge.rs create mode 100644 crates/wasm/src/runtime.rs create mode 100644 packages/core/src/compile.ts delete mode 100644 packages/core/src/computed.ts create mode 100644 packages/core/src/from-wasm.ts delete mode 100644 packages/core/src/transitions.ts create mode 100644 packages/core/src/wasm.ts create mode 100644 packages/core/tests/from-wasm.test.ts delete mode 100644 packages/demo-wasm/package.json delete mode 100644 packages/demo-wasm/src/index.ts delete mode 100644 packages/demo-wasm/src/node.ts delete mode 100644 packages/demo-wasm/tests/parity.test.ts delete mode 100644 packages/demo-wasm/tsconfig.json delete mode 100644 packages/native-rust/.gitignore delete mode 100644 packages/native-rust/README.md delete mode 100644 packages/native-rust/package.json delete mode 100644 packages/native-rust/src/index.ts delete mode 100644 packages/native-rust/tsconfig.json delete mode 100644 packages/native-rust/ubrn.config.yaml delete mode 100644 packages/wasm/LICENSE delete mode 100644 packages/wasm/README.md delete mode 100644 packages/wasm/package.json delete mode 100644 packages/wasm/src/index.ts delete mode 100644 packages/wasm/tests/adapter.test.ts delete mode 100644 packages/wasm/tsconfig.json delete mode 100644 sandbox/native/rust-source.ts create mode 100644 sandbox/shared/rust/Cargo.toml rename {crates/demo => sandbox/shared/rust}/src/dialog.rs (100%) create mode 100644 sandbox/shared/rust/src/lib.rs rename {crates/demo => sandbox/shared/rust}/src/palette.rs (100%) create mode 100644 sandbox/shared/src/rust-node.ts create mode 100644 sandbox/shared/src/rust.ts create mode 100644 sandbox/shared/tests/rust.test.ts diff --git a/.changeset/export-broadcast-selection.md b/.changeset/export-broadcast-selection.md deleted file mode 100644 index fa249f7..0000000 --- a/.changeset/export-broadcast-selection.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@dunky.dev/state-machine': minor ---- - -Export `makeBroadcast` (with its `Broadcast` type) and `makeSelection` — the -notify and select primitives the engine's own `subscribe` and `select` are -built on — for code that implements the `Machine` interface on another -engine. `@dunky.dev/state-machine-wasm` uses them for its Rust-backed -machines, so every implementation shares one set of semantics instead of -re-deriving them: a broadcast that allocates nothing on steady-state notifies -and honors (un)subscribes made mid-notify, and selections that fire only when -the selected value changes (`Object.is`, or the `equals` you pass to -`subscribe`). - -```ts -import { makeBroadcast, makeSelection } from '@dunky.dev/state-machine' - -const broadcast = makeBroadcast() -const count = makeSelection( - () => store.count, - onWake => broadcast.add(onWake), -) -count.subscribe(value => render(value)) // fires only when `count` changed -broadcast.notify() // after every change -``` - -No behavior change for existing consumers. diff --git a/.changeset/rust-engine.md b/.changeset/rust-engine.md new file mode 100644 index 0000000..ba70aa8 --- /dev/null +++ b/.changeset/rust-engine.md @@ -0,0 +1,39 @@ +--- +'@dunky.dev/state-machine': minor +--- + +The engine is now Rust, compiled to WebAssembly. The API does not change: +`machine(config)`, `setup`, the connector and every target work as before, and +the 0.x test suite passes unchanged on the new engine. Rust runs the graph — the +queue, transition resolution, entry/exit order, effects, `after` timers, watchers +and computed bookkeeping — and calls back into your guards, actions, effects, +delays and computed definitions. The wasm is inlined, so the package starts +synchronously on the first `machine()` call: there is no `await init()`. + +Machines can now also be written in Rust, on the `dunky-core` crate. Export the +machine type with `dunky_wasm::export_machine!`, build it with `wasm-bindgen`, +and wrap an instance with `fromWasm`: the connector, `useMachine` and every +target consume it like a TS machine, with the same notifications (one per +change, in order). + +```ts +import { fromWasm } from '@dunky.dev/state-machine' +import init, { PaletteMachine } from './pkg/machines.js' // your wasm-bindgen output + +await init() +const palette = fromWasm(new PaletteMachine(commands)) +``` + +The `computed` option of `fromWasm` maps a raw value once per change — for +example, indices that crossed the boundary back onto your own objects. + +Breaking: + +- The package needs WebAssembly: browsers, Node, Bun and Deno have it. React + Native (Hermes) does not, so this version does not run there yet. +- The runaway guard (one run-to-completion step over 10,000 events or watcher + runs) now throws in production too, instead of hanging. +- Computed values refresh on writes made through `setContext`, like watchers and + subscribers always did; mutating the context object directly does not refresh + them. +- The bundle carries the engine: about 75 kB gzip. diff --git a/.changeset/use-machine-accepts-machine.md b/.changeset/use-machine-accepts-machine.md index b64c103..076cb00 100644 --- a/.changeset/use-machine-accepts-machine.md +++ b/.changeset/use-machine-accepts-machine.md @@ -5,16 +5,16 @@ --- `useMachine` now accepts a ready machine as well as a config. The first argument -may return either, so a machine built outside the TS engine — for example a Rust -machine compiled to wasm and wrapped by `@dunky.dev/state-machine-wasm` — plugs -into the same hook, connector and component effects: +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, run by the TS engine (unchanged) +// a config (unchanged) useMachine(commandPaletteMachineConfig, connect, effects, props) -// a ready machine, e.g. Rust via wasm -useMachine(props => createPalette(props.commands), connect, effects, props) +// a ready machine, e.g. one written in Rust +useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props) ``` A ready machine must come fresh from the factory (one per component instance): the diff --git a/.changeset/wasm-adapter-package.md b/.changeset/wasm-adapter-package.md deleted file mode 100644 index a3bcc09..0000000 --- a/.changeset/wasm-adapter-package.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@dunky.dev/state-machine-wasm': minor ---- - -Add `@dunky.dev/state-machine-wasm` — run a machine written in Rust (on the -`dunky-core` crate) and compiled to wasm behind the same `Machine` interface -as the TS engine. Export the machine type from Rust with -`dunky_wasm::export_machine!`, build it with `wasm-bindgen`, and wrap an -instance with `fromWasm`: the connector, `useMachine` and every target (React, -Solid, React Native, OpenTUI) consume it unchanged. - -```ts -import { fromWasm } from '@dunky.dev/state-machine-wasm' -import init, { PaletteMachine } from './pkg/machines.js' // wasm-bindgen output - -await init() -const palette = fromWasm(new PaletteMachine(commands)) -palette.start() -palette.send({ type: 'open' }) -``` - -Reads stay plain property reads. `context` is a JS mirror with a stable -identity, updated in place: every call into Rust returns a change mask, so -only the fields that changed cross the boundary. Computed values are cached -until Rust reports a new version, and the `computed` option maps a raw value -once per change — for example, indices that crossed the boundary back onto -your own objects. Timers run on the host: the machine asks for them, and the -adapter schedules them with `setTimeout`, or with the `scheduler` you pass. - -One deliberate difference from the TS engine: subscribers are notified once -per call (`send`, `start`, `stop`, a timer firing), after the whole -run-to-completion step, instead of once per change inside it. diff --git a/.github/actions/wasm-setup/action.yml b/.github/actions/wasm-setup/action.yml index 8453484..31ffaa1 100644 --- a/.github/actions/wasm-setup/action.yml +++ b/.github/actions/wasm-setup/action.yml @@ -1,5 +1,5 @@ name: Setup wasm -description: Build the generated wasm package (packages/demo-wasm/pkg) for jobs that import it. Runs after ci-setup. +description: Build the wasm modules (pnpm build:wasm) — the engine inside @dunky.dev/state-machine, and the Rust machines of the sandbox and the benchmark — for jobs that import them. Runs after ci-setup. runs: using: composite diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index f7e8c0d..9ff7ddf 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -25,6 +25,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup + # @dunky.dev/state-machine ships the engine's wasm (packages/core/wasm). + - uses: ./.github/actions/wasm-setup - run: pnpm build test: @@ -33,7 +35,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup - # The demo-wasm parity tests load the generated wasm package. + # The engine (packages/core/wasm) and the Rust machines of the sandbox tests. - uses: ./.github/actions/wasm-setup - run: pnpm test:ci @@ -43,7 +45,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup - # demo-wasm (and the benchmark, through it) import the generated wasm package. + # The core package and the Rust-machine loaders import the generated bindings. - uses: ./.github/actions/wasm-setup - run: pnpm typecheck diff --git a/.github/workflows/deploy-benchmark.yml b/.github/workflows/deploy-benchmark.yml index 88ad315..ea19de7 100644 --- a/.github/workflows/deploy-benchmark.yml +++ b/.github/workflows/deploy-benchmark.yml @@ -21,6 +21,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup + # The demo runs @dunky.dev/state-machine, which loads the engine's wasm. + - uses: ./.github/actions/wasm-setup - name: Build demo # The canonical demo ships with the docs site at diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 375615f..5b241a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,8 @@ jobs: - uses: ./.github/actions/ci-setup with: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # @dunky.dev/state-machine ships the engine's wasm (packages/core/wasm). + - uses: ./.github/actions/wasm-setup - name: Create release PR or publish id: changesets diff --git a/.gitignore b/.gitignore index 24e93f2..8efd014 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,9 @@ dist # Rust /target # wasm-bindgen output (built by `pnpm build:wasm`) -packages/demo-wasm/pkg +packages/core/wasm +sandbox/shared/rust/pkg +benchmark/rust/pkg # Expo prebuild output (`expo prebuild` / `expo run:*` in the RN sandbox) sandbox/native/ios diff --git a/.oxlintrc.json b/.oxlintrc.json index 9e7d62e..ba914b6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,5 +45,10 @@ } }, "globals": {}, - "ignorePatterns": ["**/dist/**", "website/src/snippets/**", "packages/demo-wasm/pkg/**"] + "ignorePatterns": [ + "**/dist/**", + "website/src/snippets/**", + "packages/core/wasm/**", + "**/rust/pkg/**" + ] } diff --git a/Cargo.lock b/Cargo.lock index a5ce33d..97da357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,29 +15,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] -name = "dunky-core" +name = "dunky-benchmark" version = "0.1.0" dependencies = [ - "dunky-macros", + "dunky-core", + "dunky-sandbox", + "dunky-wasm", "serde", - "serde_json", + "wasm-bindgen", ] [[package]] -name = "dunky-demo" +name = "dunky-core" version = "0.1.0" dependencies = [ - "dunky-core", + "dunky-macros", "serde", "serde_json", ] [[package]] -name = "dunky-demo-wasm" +name = "dunky-core-wasm" version = "0.1.0" dependencies = [ - "dunky-core", - "dunky-demo", "dunky-wasm", "wasm-bindgen", ] @@ -51,6 +51,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dunky-sandbox" +version = "0.1.0" +dependencies = [ + "dunky-core", + "dunky-wasm", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "dunky-wasm" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 364a064..19feb81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,16 @@ -# Rust port of the engine. Members are listed explicitly so a half-built crate +# The Rust engine, its bindings, and the machines the sandbox and the benchmark +# write in Rust. Members are listed explicitly so a half-built crate # never breaks `cargo test --workspace` for the others. [workspace] resolver = "3" -members = ["crates/core", "crates/macros", "crates/wasm", "crates/demo", "crates/demo-wasm"] +members = [ + "crates/core", + "crates/macros", + "crates/wasm", + "crates/core-wasm", + "sandbox/shared/rust", + "benchmark/rust", +] # Built by its own pipeline (uniffi-bindgen-react-native) for iOS/Android. exclude = ["crates/uniffi"] @@ -17,7 +25,7 @@ rust-version = "1.85" dunky-core = { path = "crates/core" } dunky-macros = { path = "crates/macros" } dunky-wasm = { path = "crates/wasm" } -dunky-demo = { path = "crates/demo" } +dunky-sandbox = { path = "sandbox/shared/rust" } serde = { version = "1", features = ["derive"] } serde_json = "1" # Must match the installed wasm-bindgen CLI exactly (wasm-bindgen --version). diff --git a/benchmark/package.json b/benchmark/package.json index ea686bb..d4a8d29 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -7,7 +7,6 @@ "benchmark": "NODE_ENV=production node --expose-gc --import tsx index.ts" }, "dependencies": { - "@dunky.dev/demo-wasm": "workspace:*", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", "@sandbox/cmdk-core": "workspace:*" diff --git a/benchmark/rust.ts b/benchmark/rust.ts new file mode 100644 index 0000000..96aa338 --- /dev/null +++ b/benchmark/rust.ts @@ -0,0 +1,98 @@ +/** + * The benchmark machines written in Rust (benchmark/rust), wrapped with `fromWasm` — each + * one the twin of a TS machine in this suite. Needs the wasm build (`pnpm build:wasm`); + * `loadRust()` before building machines. + */ +import { readFileSync } from 'node:fs' +import { fromWasm, type Machine } from '@dunky.dev/state-machine' +import type { + Command, + CommandPaletteComputed, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteState, +} from '@sandbox/cmdk-core' +import { + CellMachine, + GuardsMachine, + HighlightMachine, + initSync, + ListMachine, + noop, + PaletteMachine, + PingPongMachine, + sink, +} from './rust/pkg/dunky_benchmark.js' + +export { noop, sink } + +export const WASM_URL = new URL('./rust/pkg/dunky_benchmark_bg.wasm', import.meta.url) + +let loaded = false +/** Load the wasm synchronously (idempotent). */ +export function loadRust(): void { + if (loaded) return + initSync({ module: readFileSync(WASM_URL) }) + loaded = true +} + +type Palette = Machine< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed +> + +/** The command palette; `results` crosses as indices, mapped onto the caller's objects. */ +export function createPalette(commands: Command[]): Palette { + return fromWasm< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed + >(new PaletteMachine(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as ArrayLike, i => commands[i]!), + }, + }, + }) +} + +/** The same palette, with `results` crossing as full objects (for comparison). */ +export function createPaletteObjects(commands: Command[]): Palette { + return fromWasm(new PaletteMachine(commands)) +} + +export type CellContext = { value: number; other: number } +export function createCell(): Machine<'idle', CellContext, { type: 'hit' | 'miss' }> { + return fromWasm(new CellMachine()) +} + +export function createPingPong(): Machine<'ping' | 'pong', object, { type: 'go' }> { + return fromWasm(new PingPongMachine()) +} + +export function createGuards(k: number): Machine<'idle', { pick: number }, { type: 'go' }> { + return fromWasm(new GuardsMachine(k)) +} + +/** The rendering bench's shared list: one highlighted index. */ +export function createList(): Machine< + 'idle', + { highlighted: number }, + { type: 'move'; to: number } +> { + return fromWasm(new ListMachine()) +} + +/** The rendering bench's per-row machine: one boolean. */ +export function createHighlight( + on: boolean, +): Machine<'idle', { on: boolean }, { type: 'set'; on: boolean }> { + return fromWasm(new HighlightMachine(on)) +} + +/** Raw handles, for measuring the boundary without the facade. */ +export const raw = { CellMachine, PingPongMachine } diff --git a/benchmark/rust/Cargo.toml b/benchmark/rust/Cargo.toml new file mode 100644 index 0000000..8c985ba --- /dev/null +++ b/benchmark/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dunky-benchmark" +description = "The benchmark machines written in Rust, 1:1 twins of the TS ones, exported to wasm for the TS-vs-Rust rows." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +dunky-core = { workspace = true } +dunky-wasm = { workspace = true } +dunky-sandbox = { workspace = true } +serde = { workspace = true } +wasm-bindgen = { workspace = true } diff --git a/crates/demo/src/cell.rs b/benchmark/rust/src/cell.rs similarity index 100% rename from crates/demo/src/cell.rs rename to benchmark/rust/src/cell.rs diff --git a/crates/demo/src/guards.rs b/benchmark/rust/src/guards.rs similarity index 100% rename from crates/demo/src/guards.rs rename to benchmark/rust/src/guards.rs diff --git a/crates/demo/src/highlight.rs b/benchmark/rust/src/highlight.rs similarity index 100% rename from crates/demo/src/highlight.rs rename to benchmark/rust/src/highlight.rs diff --git a/benchmark/rust/src/lib.rs b/benchmark/rust/src/lib.rs new file mode 100644 index 0000000..609cf94 --- /dev/null +++ b/benchmark/rust/src/lib.rs @@ -0,0 +1,101 @@ +//! The benchmark machines, written in Rust against `dunky-core` — each one the twin of a +//! TS machine in `benchmark/`, so a row compares the same work: +//! +//! - [`cell`], [`pingpong`], [`guards`] — the engine shapes. +//! - [`list`], [`highlight`] — the React rendering bench's shared list and per-row machine. +//! - The command palette comes from the sandbox (`dunky_sandbox::palette`). +//! +//! Built to wasm by `pnpm build:wasm` (`benchmark/rust/pkg`). + +use std::cell::Cell as StdCell; + +use dunky_core::{Config, Machine}; +use dunky_sandbox::palette; +use wasm_bindgen::prelude::*; + +pub mod cell; +pub mod guards; +pub mod highlight; +pub mod list; +pub mod pingpong; + +thread_local! { + static SINK: StdCell = const { StdCell::new(0) }; +} + +/// Entry/exit/guard actions bump the sink so the work is not optimized away. +pub fn bump() { + SINK.with(|s| s.set(s.get() + 1)); +} + +/// The sink, read once per run as proof the actions ran. +#[wasm_bindgen] +pub fn sink() -> f64 { + SINK.with(StdCell::get) as f64 +} + +/// Raw boundary cost: one number in, one number out, no work. +#[wasm_bindgen] +pub fn noop(x: u32) -> u32 { + x +} + +// One config per machine type, shared by every instance (the shape a real app has). +thread_local! { + static PALETTE: Config = palette::config(Vec::new()); + static CELL: Config = cell::config(); + static PINGPONG: Config = pingpong::config(); + static LIST: Config = list::config(); + static HIGHLIGHT: Config = highlight::config(); +} + +dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + dunky_wasm::from_js::>(commands) + .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) + } + computed { + palette::RESULTS => Vec, + palette::ACTIVE_ID => Option, + palette::RESULT_INDICES => Vec, + } +} + +dunky_wasm::export_machine! { + /// `hit` bumps `value`, `miss` bumps `other`. + pub struct CellMachine(cell::Cell); + new() { CELL.with(Machine::new) } + computed {} +} + +dunky_wasm::export_machine! { + /// Ping <-> pong with entry/exit actions on every transition. + pub struct PingPongMachine(pingpong::PingPong); + new() { PINGPONG.with(Machine::new) } + computed {} +} + +dunky_wasm::export_machine! { + /// `k` guarded candidates, the last one wins. + pub struct GuardsMachine(guards::Guards); + new(k: u32) { Machine::new(&guards::config(k.max(1))) } + computed {} +} + +dunky_wasm::export_machine! { + /// The rendering bench's shared list (one highlighted index). + pub struct ListMachine(list::List); + new() { LIST.with(Machine::new) } + computed {} +} + +dunky_wasm::export_machine! { + /// The rendering bench's per-row machine (one boolean). + pub struct HighlightMachine(highlight::Highlight); + new(on: bool) { + HIGHLIGHT.with(|c| Machine::with_context(c, highlight::HighlightCtx { on })) + } + computed {} +} diff --git a/crates/demo/src/list.rs b/benchmark/rust/src/list.rs similarity index 100% rename from crates/demo/src/list.rs rename to benchmark/rust/src/list.rs diff --git a/crates/demo/src/pingpong.rs b/benchmark/rust/src/pingpong.rs similarity index 100% rename from crates/demo/src/pingpong.rs rename to benchmark/rust/src/pingpong.rs diff --git a/benchmark/tests/rendering/bench.tsx b/benchmark/tests/rendering/bench.tsx index 4f91786..402fc00 100644 --- a/benchmark/tests/rendering/bench.tsx +++ b/benchmark/tests/rendering/bench.tsx @@ -114,7 +114,7 @@ const renderCounts = { 'core/instance': 0, 'zag/instance': 0, 'xstate/selector': 0, - // The same rows over the Rust core (crates/demo → wasm, via @dunky.dev/state-machine-wasm). + // The same rows over machines written in Rust (benchmark/rust → wasm, via fromWasm). 'wasm/selector': 0, 'wasm/instance': 0, } @@ -319,11 +319,11 @@ export async function runRenderingBench(N: number, moves: number) { } // --- The same two arenas over the Rust core (wasm), when it is built --- - const demo = wasmBuilt() ? await import('@dunky.dev/demo-wasm/node') : null - if (!demo) console.warn(WASM_SKIPPED) - if (demo) { - demo.loadDemoNode() // idempotent; the suite's wasm section already loaded it - const { createHighlight, createList } = demo + const rust = wasmBuilt() ? await import('../../rust') : null + if (!rust) console.warn(WASM_SKIPPED) + if (rust) { + rust.loadRust() // idempotent; the suite's wasm section already loaded it + const { createHighlight, createList } = rust const m = createList() m.start() results['wasm/selector'] = run( diff --git a/benchmark/tests/wasm.ts b/benchmark/tests/wasm.ts index 075d234..87b97cd 100644 --- a/benchmark/tests/wasm.ts +++ b/benchmark/tests/wasm.ts @@ -1,12 +1,12 @@ /** - * SPIKE — the TS core vs the Rust core (crates/core) compiled to wasm. + * A machine written in TS vs the same machine written in Rust — both on the Rust engine. * - * Every Rust row runs the SAME machine as its TS row (crates/demo ports them 1:1): + * Every Rust row runs the SAME machine as its TS row (benchmark/rust ports them 1:1): * - * wasm/adapter — through @dunky.dev/state-machine-wasm: the TS `Machine` interface - * over the wasm handle (JS context mirror, change mask, timers). This - * is what a React/Solid target actually calls. - * wasm/raw — the bare wasm-bindgen handle, no adapter: the boundary floor. + * ts machine — `machine(config)`: the engine (wasm) calls back into the TS user code. + * rust machine — through `fromWasm`: the `Machine` interface over a Rust machine in its + * own wasm module. This is what a React/Solid target actually calls. + * rust raw — the bare wasm-bindgen handle, no facade: the boundary floor. * * Needs the wasm build (`pnpm build:wasm`). Set SPIKE_OUT= to also write the * numbers as JSON (the spike report reads them). @@ -24,10 +24,11 @@ import { createPalette, createPaletteObjects, createPingPong, - loadDemoNode, + loadRust, noop, raw, -} from '@dunky.dev/demo-wasm/node' + WASM_URL, +} from '../rust' import { report } from '../report' const SINK = { n: 0 } @@ -66,7 +67,7 @@ async function run(id: string, title: string, bench: Bench): Promise { const newBench = () => new Bench({ time: 500, warmupTime: 100 }) -// --- TS twins of the Rust demo machines (crates/demo) ------------------------ +// --- TS twins of the Rust machines (benchmark/rust) -------------------------- type CellCtx = { value: number; other: number } const cellConfig = { initial: 'idle' as const, @@ -163,11 +164,9 @@ function benchCell(field: 'hit' | 'miss') { handle.start() const kind = field === 'hit' ? 0 : 1 const event = { type: field } as const - b.add('ts core', () => ts.send(event)) - b.add('wasm/adapter', () => rs.send(event)) - b.add('wasm/raw sendKind', () => { - SINK.n += handle.sendKind(kind) - }) + b.add('ts machine', () => ts.send(event)) + b.add('rust machine', () => rs.send(event)) + b.add('rust raw', () => handle.send(kind, event)) return b } @@ -181,11 +180,9 @@ function benchPingPong() { const handle = new raw.PingPongMachine() handle.start() const go = { type: 'go' } as const - b.add('ts core', () => ts.send(go)) - b.add('wasm/adapter', () => rs.send(go)) - b.add('wasm/raw sendKind', () => { - SINK.n += handle.sendKind(0) - }) + b.add('ts machine', () => ts.send(go)) + b.add('rust machine', () => rs.send(go)) + b.add('rust raw', () => handle.send(0, go)) return b } @@ -197,8 +194,8 @@ function benchGuards(k: number) { const rs = createGuards(k) rs.start() const go = { type: 'go' } as const - b.add('ts core', () => ts.send(go)) - b.add('wasm/adapter', () => rs.send(go)) + b.add('ts machine', () => ts.send(go)) + b.add('rust machine', () => rs.send(go)) return b } @@ -220,47 +217,46 @@ function benchPalette(n: number) { m.send({ type: 'query.set', query: QUERIES[i++ % QUERIES.length]! }) SINK.n += m.computed.results.length + (m.computed.activeId?.length ?? 0) } - b.add('ts core', () => typeAndRead(ts)) - b.add('wasm/adapter (indices → own objects)', () => typeAndRead(rsIdx)) - b.add('wasm/adapter (objects cross)', () => typeAndRead(rsObj)) + b.add('ts machine', () => typeAndRead(ts)) + b.add('rust machine (indices → own objects)', () => typeAndRead(rsIdx)) + b.add('rust machine (objects cross)', () => typeAndRead(rsObj)) return b } // --- F. construction: build + start one machine --------------------------------- function benchConstruct() { const b = newBench() - b.add('ts core: machine(config) + start', () => { + b.add('ts machine: machine(config) + start', () => { const m = tsCell() m.start() SINK.n += m.context.value }) - b.add('wasm/adapter: createCell() + start', () => { + b.add('rust machine: createCell() + start', () => { const m = createCell() m.start() SINK.n += m.context.value }) - b.add('wasm/raw: new CellMachine() + start', () => { + b.add('rust raw: new CellMachine() + start', () => { const h = new raw.CellMachine() - SINK.n += h.start() + SINK.n += h.start() === 0 ? 1 : 0 }) return b } function measureInit(): { compileMs: number; firstInitMs: number } { - const url = new URL('../../packages/demo-wasm/pkg/dunky_demo_wasm_bg.wasm', import.meta.url) - const bytes = readFileSync(url) + const bytes = readFileSync(WASM_URL) const runs = 20 const t0 = performance.now() for (let i = 0; i < runs; i++) new WebAssembly.Module(bytes) const compileMs = (performance.now() - t0) / runs const t1 = performance.now() - loadDemoNode() + loadRust() const firstInitMs = performance.now() - t1 return { compileMs, firstInitMs } } export async function runWasm() { - console.log('\n========== SPIKE — TS core vs Rust core (wasm) ==========') + console.log('\n========== TS machines vs Rust machines (both on the Rust engine) ==========') const init = measureInit() console.log( `wasm init: compile ${init.compileMs.toFixed(2)} ms (avg of 20) · first initSync ${init.firstInitMs.toFixed(2)} ms`, diff --git a/benchmark/wasm-built.ts b/benchmark/wasm-built.ts index dc68314..d2c75d5 100644 --- a/benchmark/wasm-built.ts +++ b/benchmark/wasm-built.ts @@ -1,12 +1,12 @@ import { existsSync } from 'node:fs' /** - * Whether the demo machines were built to wasm (`pnpm build:wasm`). The wasm rows are - * skipped when they were not, so the TS benchmarks run without a Rust toolchain. + * Whether the benchmark machines were built to wasm (`pnpm build:wasm`). The Rust rows + * are skipped when they were not. */ export function wasmBuilt(): boolean { - return existsSync(new URL('../packages/demo-wasm/pkg/dunky_demo_wasm_bg.wasm', import.meta.url)) + return existsSync(new URL('./rust/pkg/dunky_benchmark_bg.wasm', import.meta.url)) } export const WASM_SKIPPED = - '⚠️ wasm not built — skipping the Rust/wasm rows (run `pnpm build:wasm`).' + '⚠️ wasm not built — skipping the Rust-machine rows (run `pnpm build:wasm`).' diff --git a/crates/core-wasm/Cargo.toml b/crates/core-wasm/Cargo.toml new file mode 100644 index 0000000..ac0780b --- /dev/null +++ b/crates/core-wasm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-core-wasm" +description = "The wasm that @dunky.dev/state-machine ships: the Rust engine plus the runtime for TS-authored machines." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dunky-wasm = { workspace = true, features = ["runtime"] } +wasm-bindgen = { workspace = true } diff --git a/crates/core-wasm/src/lib.rs b/crates/core-wasm/src/lib.rs new file mode 100644 index 0000000..c1f84c1 --- /dev/null +++ b/crates/core-wasm/src/lib.rs @@ -0,0 +1,48 @@ +//! The wasm module of `@dunky.dev/state-machine`: the runtime for TS-authored machines +//! (see `dunky_wasm::runtime`). Built by `pnpm build:wasm` into `packages/core/wasm`. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use wasm_bindgen::prelude::*; + +pub use dunky_wasm::runtime::{JsConfig, JsMachine}; + +// The engine's memory lives outside the JS heap, so a JS heap sample misses it. The +// allocator counts the live bytes for the benchmark's memory section. +struct Counted; + +static LIVE: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counted { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let ptr = unsafe { System.alloc(layout) }; + if !ptr.is_null() { + LIVE.fetch_add(layout.size(), Ordering::Relaxed); + } + ptr + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) }; + LIVE.fetch_sub(layout.size(), Ordering::Relaxed); + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + let moved = unsafe { System.realloc(ptr, layout, size) }; + if !moved.is_null() { + LIVE.fetch_add(size, Ordering::Relaxed); + LIVE.fetch_sub(layout.size(), Ordering::Relaxed); + } + moved + } +} + +#[global_allocator] +static ALLOCATOR: Counted = Counted; + +/// The engine's live heap bytes. +#[wasm_bindgen(js_name = engineHeapBytes)] +pub fn engine_heap_bytes() -> f64 { + LIVE.load(Ordering::Relaxed) as f64 +} diff --git a/crates/demo-wasm/Cargo.toml b/crates/demo-wasm/Cargo.toml deleted file mode 100644 index 324e243..0000000 --- a/crates/demo-wasm/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "dunky-demo-wasm" -description = "The demo machines compiled to wasm (cdylib) for the JS adapter, benchmark and sandboxes." -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[features] -# Every demo machine. Build a single one (for size numbers) with e.g. -# `--no-default-features --features dialog`. -default = ["palette", "cell", "pingpong", "guards", "dialog", "list", "highlight"] -palette = [] -cell = [] -pingpong = [] -guards = [] -dialog = [] -list = [] -highlight = [] - -[dependencies] -dunky-core = { workspace = true } -dunky-wasm = { workspace = true } -dunky-demo = { workspace = true } -wasm-bindgen = { workspace = true } diff --git a/crates/demo-wasm/src/lib.rs b/crates/demo-wasm/src/lib.rs deleted file mode 100644 index 86c93cc..0000000 --- a/crates/demo-wasm/src/lib.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! The demo machines as JS classes (wasm-bindgen). Built by `pnpm build:wasm` into -//! `packages/demo-wasm/pkg`. - -#![allow(unused_imports)] - -use dunky_core::{Config, Machine}; -use dunky_demo::{cell, dialog, guards, highlight, list, palette, pingpong}; -use wasm_bindgen::prelude::*; - -// One config per machine type, shared by every instance (the shape a real app has). -#[cfg(feature = "palette")] -thread_local!(static PALETTE: Config = palette::config(Vec::new())); -#[cfg(feature = "cell")] -thread_local!(static CELL: Config = cell::config()); -#[cfg(feature = "pingpong")] -thread_local!(static PINGPONG: Config = pingpong::config()); -#[cfg(feature = "dialog")] -thread_local!(static DIALOG: Config = dialog::config(0)); - -// The sandbox command palette. -#[cfg(feature = "palette")] -dunky_wasm::export_machine! { - pub struct PaletteMachine(palette::Palette); - new(commands: JsValue) { - // Malformed commands throw: the host maps result indices onto its own array. - dunky_wasm::from_js::>(commands) - .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) - } - computed { - palette::RESULTS => Vec, - palette::ACTIVE_ID => Option, - palette::RESULT_INDICES => Vec, - } -} - -// The benchmark cell: `hit` bumps `value`, `miss` bumps `other`. -#[cfg(feature = "cell")] -dunky_wasm::export_machine! { - pub struct CellMachine(cell::Cell); - new() { CELL.with(Machine::new) } - computed {} -} - -// Ping ⇄ pong with entry/exit actions on every transition. -#[cfg(feature = "pingpong")] -dunky_wasm::export_machine! { - pub struct PingPongMachine(pingpong::PingPong); - new() { PINGPONG.with(Machine::new) } - computed {} -} - -// `k` guarded candidates, the last one wins. -#[cfg(feature = "guards")] -dunky_wasm::export_machine! { - pub struct GuardsMachine(guards::Guards); - new(k: u32) { Machine::new(&guards::config(k.max(1))) } - computed {} -} - -// A dialog whose `closing` phase lasts `exitMs`. -#[cfg(feature = "dialog")] -dunky_wasm::export_machine! { - pub struct DialogMachine(dialog::Dialog); - new(exit_ms: u32) { - DIALOG.with(|c| Machine::with_context(c, dialog::DialogCtx { exit_ms, open_count: 0 })) - } - computed {} -} - -#[cfg(feature = "list")] -thread_local!(static LIST: Config = list::config()); -#[cfg(feature = "highlight")] -thread_local!(static HIGHLIGHT: Config = highlight::config()); - -// The rendering bench's shared list (one highlighted index). -#[cfg(feature = "list")] -dunky_wasm::export_machine! { - pub struct ListMachine(list::List); - new() { LIST.with(Machine::new) } - computed {} -} - -// The rendering bench's per-row machine (one boolean). -#[cfg(feature = "highlight")] -dunky_wasm::export_machine! { - pub struct HighlightMachine(highlight::Highlight); - new(on: bool) { - HIGHLIGHT.with(|c| Machine::with_context(c, highlight::HighlightCtx { on })) - } - computed {} -} - -/// Raw boundary cost: one number in, one number out, no work. -#[wasm_bindgen] -pub fn noop(x: u32) -> u32 { - x -} - -/// The benchmark sink (proof the entry/exit/guard actions ran). -#[wasm_bindgen] -pub fn sink() -> f64 { - dunky_demo::sink() as f64 -} diff --git a/crates/demo/Cargo.toml b/crates/demo/Cargo.toml deleted file mode 100644 index 34bbaf2..0000000 --- a/crates/demo/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "dunky-demo" -description = "Example machines written against dunky-core (palette, cell, ping-pong, dialog). Used by the wasm demo, the benchmark and the sandboxes." -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false - -[dependencies] -dunky-core = { workspace = true } -serde = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/crates/demo/src/lib.rs b/crates/demo/src/lib.rs deleted file mode 100644 index 7b299ff..0000000 --- a/crates/demo/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Example machines written against `dunky-core`. -//! -//! - [`palette`] — the sandbox command palette, a faithful port of -//! `sandbox/shared/src/machine.ts` (computed filtering, logical navigation). -//! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). -//! - [`cell`], [`pingpong`], [`guards`] — the benchmark shapes from `benchmark/`. -//! - [`list`], [`highlight`] — the React rendering bench's shared list and per-row machine. - -use std::cell::Cell as StdCell; - -pub mod cell; -pub mod dialog; -pub mod guards; -pub mod highlight; -pub mod list; -pub mod palette; -pub mod pingpong; - -thread_local! { - static SINK: StdCell = const { StdCell::new(0) }; -} - -/// Benchmark sink: entry/exit/guard actions bump it so the work is not optimized away. -pub fn bump() { - SINK.with(|s| s.set(s.get() + 1)); -} - -pub fn sink() -> u64 { - SINK.with(StdCell::get) -} diff --git a/crates/uniffi/Cargo.lock b/crates/uniffi/Cargo.lock index 855fac3..ab90c1c 100644 --- a/crates/uniffi/Cargo.lock +++ b/crates/uniffi/Cargo.lock @@ -71,12 +71,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - [[package]] name = "bytes" version = "1.12.1" @@ -129,14 +123,6 @@ dependencies = [ "serde", ] -[[package]] -name = "dunky-demo" -version = "0.1.0" -dependencies = [ - "dunky-core", - "serde", -] - [[package]] name = "dunky-macros" version = "0.1.0" @@ -147,25 +133,21 @@ dependencies = [ ] [[package]] -name = "dunky-uniffi" +name = "dunky-sandbox" version = "0.1.0" dependencies = [ "dunky-core", - "dunky-demo", - "dunky-wasm", - "serde_json", - "uniffi", + "serde", ] [[package]] -name = "dunky-wasm" +name = "dunky-uniffi" version = "0.1.0" dependencies = [ "dunky-core", - "js-sys", - "serde", - "serde-wasm-bindgen", - "wasm-bindgen", + "dunky-sandbox", + "serde_json", + "uniffi", ] [[package]] @@ -199,30 +181,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -281,17 +239,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - [[package]] name = "libc" version = "0.2.190" @@ -344,12 +291,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - [[package]] name = "plain" version = "0.2.3" @@ -399,12 +340,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - [[package]] name = "scroll" version = "0.12.0" @@ -445,17 +380,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde_core" version = "1.0.229" @@ -504,12 +428,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smawk" version = "0.3.3" @@ -757,51 +675,6 @@ dependencies = [ "weedle2", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" -dependencies = [ - "unicode-ident", -] - [[package]] name = "weedle2" version = "5.0.0" diff --git a/crates/uniffi/Cargo.toml b/crates/uniffi/Cargo.toml index fe8cdf3..3f84878 100644 --- a/crates/uniffi/Cargo.toml +++ b/crates/uniffi/Cargo.toml @@ -1,6 +1,6 @@ -# React Native binding of the engine, built by uniffi-bindgen-react-native -# (packages/native-rust). Standalone on purpose: the root workspace excludes it, -# so its mobile-only toolchain can never break the other crates' builds. +# 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. [package] name = "dunky-uniffi" version = "0.1.0" @@ -17,12 +17,8 @@ crate-type = ["cdylib", "staticlib"] [dependencies] dunky-core = { path = "../core" } -dunky-demo = { path = "../demo" } +dunky-sandbox = { path = "../../sandbox/shared/rust" } serde_json = "1" # Must stay on the uniffi minor that uniffi-bindgen-react-native is built with # (ubrn 0.31.0-6 -> uniffi 0.31): the generated C++/TS check the contract version. uniffi = "=0.31" - -[dev-dependencies] -# The change-mask parity test drives the wasm binding's `Handle` next to this one. -dunky-wasm = { path = "../wasm" } diff --git a/crates/uniffi/src/lib.rs b/crates/uniffi/src/lib.rs index 90ecb7f..21fe352 100644 --- a/crates/uniffi/src/lib.rs +++ b/crates/uniffi/src/lib.rs @@ -1,10 +1,9 @@ //! React Native binding of the engine, exported over JSI by -//! uniffi-bindgen-react-native (see `packages/native-rust`). +//! uniffi-bindgen-react-native: the sandbox palette written in Rust. //! -//! It speaks the handle protocol of `crates/wasm` (`dunky_wasm::Handle`), so one TS -//! adapter (`@dunky.dev/state-machine-wasm`) drives both bindings: events in by kind -//! index, a u32 change mask out, timers as flat commands. Values cross as JSON strings -//! instead of JS values. +//! Events go in by kind index; a u32 change mask comes out, and timers come out as +//! flat commands (`dunky_core::protocol`). Values cross as JSON strings instead of JS +//! values. Pending its rework onto the host protocol `crates/wasm` speaks. use std::mem::ManuallyDrop; use std::sync::Arc; @@ -13,7 +12,7 @@ use std::thread::{self, ThreadId}; use dunky_core::{ Config, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, Types, }; -use dunky_demo::palette::{self, Palette, PaletteEvent}; +use dunky_sandbox::palette::{self, Palette, PaletteEvent}; uniffi::setup_scaffolding!(); @@ -79,7 +78,7 @@ impl Drop for ThreadBound { } } -/// The event type name at `kind`, or the error `dunky_wasm::Handle` throws for it. +/// The event type name at `kind`, or the error for an unknown kind. fn event_name(kind: u32) -> Result<&'static str, MachineError> { ::KIND_NAMES .get(kind as usize) @@ -138,12 +137,12 @@ fn meta_json(machine: &Machine) -> String { } thread_local! { - // One config shared by every palette, as in crates/demo-wasm. Palettes never leave + // One config shared by every palette, as in the sandbox wasm. Palettes never leave // the JS thread, so a thread-local is enough. static PALETTE: Config = palette::config(Vec::new()); } -/// The command palette of `crates/demo`, behind the handle protocol. +/// The sandbox command palette (`dunky_sandbox::palette`), behind the handle protocol. #[derive(uniffi::Object)] pub struct PaletteMachine { machine: ThreadBound>, @@ -261,8 +260,7 @@ impl PaletteMachine { #[cfg(test)] mod tests { use super::*; - use dunky_core::protocol::STATE_BIT; - use dunky_demo::palette::{MoveTo, PaletteCtx, PaletteEventKind}; + use dunky_sandbox::palette::{PaletteCtx, PaletteEventKind}; use serde_json::{Value, json}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -303,39 +301,6 @@ mod tests { assert!(dropped.load(Ordering::SeqCst)); } - #[test] - fn change_masks_match_the_wasm_binding() { - let native = palette(); - let wasm = - dunky_wasm::Handle::new(Machine::new(&palette::config(palette::demo_commands()))); - let send = |event: PaletteEvent| { - wasm.machine.send(event); - wasm.changes() - }; - - assert_eq!(native.start(), wasm.start()); - let open = native.send_kind(kind(PaletteEventKind::Open)).unwrap(); - assert_eq!(open & STATE_BIT, STATE_BIT); - assert_eq!(open, send(PaletteEvent::Open)); - assert_eq!( - native - .send_payload_json(kind(PaletteEventKind::QuerySet), r#"{"query":"go"}"#.into()) - .unwrap(), - send(PaletteEvent::QuerySet { query: "go".into() }) - ); - assert_eq!( - native - .send_payload_json(kind(PaletteEventKind::Move), r#"{"to":"down"}"#.into()) - .unwrap(), - send(PaletteEvent::Move { to: MoveTo::Down }) - ); - assert_eq!( - native.send_kind(kind(PaletteEventKind::Execute)).unwrap(), - send(PaletteEvent::Execute) - ); - assert_eq!(native.stop(), wasm.stop()); - } - #[test] fn values_cross_as_json() { let m = palette(); diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index 7141a07..0de250e 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -1,13 +1,18 @@ [package] name = "dunky-wasm" -description = "wasm-bindgen glue for dunky-core machines: export a Rust machine to JS with one macro." +description = "Run dunky-core machines from JavaScript: export a Rust machine with one macro; the runtime for TS-authored machines." version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true +[features] +# The runtime for TS-authored machines (JsConfig, JsMachine): what +# @dunky.dev/state-machine ships as its wasm. +runtime = [] + [dependencies] -dunky-core = { workspace = true } +dunky-core = { workspace = true, features = ["host"] } wasm-bindgen = { workspace = true } js-sys = { workspace = true } serde = { workspace = true } diff --git a/crates/wasm/README.md b/crates/wasm/README.md new file mode 100644 index 0000000..45b8468 --- /dev/null +++ b/crates/wasm/README.md @@ -0,0 +1,44 @@ +# dunky-wasm + +Run [`dunky-core`](../core) machines from JavaScript. Export a machine written in Rust +with one macro; wrap an instance with `fromWasm` from `@dunky.dev/state-machine`, and the +connector, `useMachine` and every target consume it like a machine written in TS. + +```rust +use dunky_core::{Config, Machine}; +use wasm_bindgen::prelude::*; + +thread_local!(static CONFIG: Config = palette::config(Vec::new())); + +dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands) + .map(|c| CONFIG.with(|config| Machine::with_context(config, palette::context(c)))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +} +``` + +Build it, then wrap it: + +```bash +cargo build --target wasm32-unknown-unknown --release +wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg target/wasm32-unknown-unknown/release/my_machines.wasm +``` + +```ts +import { fromWasm } from '@dunky.dev/state-machine' +import init, { PaletteMachine } from './pkg/my_machines.js' + +await init() +const palette = fromWasm(new PaletteMachine(commands)) +``` + +The crate using the macro also depends on `wasm-bindgen` (the version this crate pins), +and its event type derives `#[event(deserialize)]`. The `runtime` feature is the engine +for machines written in TS; `@dunky.dev/state-machine` ships it. + +What the protocol guarantees: [`SPEC.md`](./SPEC.md). diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md new file mode 100644 index 0000000..92dfa03 --- /dev/null +++ b/crates/wasm/SPEC.md @@ -0,0 +1,140 @@ +# SPEC - `dunky-wasm` + +## Overview + +`dunky-wasm` runs [`dunky-core`](../core/SPEC.md) machines from JavaScript. It owns the +**protocol** between the engine, compiled to WebAssembly, and the `Machine` facade in +[`@dunky.dev/state-machine`](../../packages/core/SPEC.md). Two producers speak it: + +- **The runtime** (cargo feature `runtime`) runs machines written in TypeScript. The + core package ships it, built from `crates/core-wasm`. +- **The export macro** turns a machine written in Rust into a JS class, in the author's + own wasm module. The core package wraps an instance with `fromWasm`. + +``` + TS machine (config) Rust machine (dunky-core types) + | | + | compiled to numbers + JS tables | export macro, your crate + v v + runtime (crates/core-wasm) exported class (your wasm module) + | | + +-----------------+-------------------+ + | the protocol + | events in .... by kind number + the JS event + | notify ....... once per change, synchronously + | timers ....... on the host clock + v + the JS host + the Machine facade (packages/core) +``` + +## Intent + +One protocol for both kinds of machine, so the facade, the connector and every target +treat them alike, and the JS side stays thin: Rust runs the graph, the timers' +bookkeeping and the change tracking; JS runs only the user code of a TS machine and the +fan-out to subscribers. + +## Scope & boundaries + +**In scope.** The protocol; the runtime for TS machines; the export macro for Rust +machines; the conversion helpers between JS values and Rust values. + +**Out of scope — invariants, not preferences.** + +- **No facade.** The `Machine` interface, the names (states, event types, fields) and the + subscribers live in the core package. +- **No platform.** Only the JS host's own functions are called: no DOM, no `window`. +- **No strong JS references.** Rust never holds a JS object strongly (see Handles). + +## The protocol + +### The facade travels with each call + +- JS passes the machine's **facade** with each call into Rust. Rust hands it to every + host function of that call, then lets it go when the outermost call returns. Rust + holds no JS object between calls, so no reference cycle through wasm keeps a machine + alive: only JS references do. + +### The host + +- One JS host object serves every machine of a module. Its functions take numbers and + return status codes, so each call crosses the boundary once, with nothing to box or + free. +- The host functions never throw. Each catches what user code throws, keeps the first + error on the facade, and returns a failure status; Rust raises the machine's halt flag + and stops the step. +- `notify(facade, state, changedLo, changedHi)`: once per effective change, in order, + synchronously, with the current state and the context fields changed since the + previous notify (the low and high 32 bits of the mask). +- `startTimer(facade, id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels + it. A due timer calls back into the machine with its id. +- For TS machines only, by index in the JS tables, with the event that caused the call + (none for a boot or a data-reaction): `guard`, `action`, `actions` (a run of actions + in one call), `pick` (a guard walk in one call: the first candidate that passes), + `effect` (returns a cleanup id, or 0), `cleanup`, `delay` and `computed` (reports its + value and its reads back before it returns). +- A TS machine's state change rides on the next `action`, `actions` or `effect` call of + its transition and is announced first; without one, it is delivered on its own before + the transition ends. + +### Calls + +- Each call into a machine runs to completion and returns a status: 0, 1 when user code + failed during this call (the facade holds the error), 3 when the engine failed (its + message comes from `takeFailure`). +- The **outermost** call also runs the timer commands. A **nested** call (user code + calling back in, e.g. a write inside an action) reports only a failure it caused; the + outer call reports it too. JS throws: the outermost call takes the error, a nested one + leaves it for the outer call. +- After a failure the machine stays usable; what was queued stays queued. + +### Timers + +- The engine's timer commands run on the host clock. A pending timer is held by the + host's closure, so it keeps its machine alive, like a JS timer holds `this`. + +### Values + +- **TS machines** keep JS values: the context is a JS object, and a derived value is a + JS value compared with `Object.is`. +- **Rust machines** convert with serde: `None` becomes `null`. A derived value is + serialized only when its version changed, and the same JS value is served until then, + so its identity marks a change. + +## The runtime — TS machines + +- A config is **compiled** once, on the JS side: states, event types and every callback + become numbers. The compiled config is shared by every machine built from it. +- The context lives in JS, and JS announces its own writes to the subscribers. It + reports a write's change mask to Rust only when the machine has watchers or derived + values: one bit per field, 64 bits, and every field past the 63rd shares the last bit. + JS also reports what each derived evaluation read: fields, the state, other derived + values. +- Every input of a derived value changes through JS, so JS serves a value read again + with no change in between without calling Rust. +- The event object crosses as is: the engine carries it — also for a timed transition + scheduled by that event — and hands it back to the callbacks. + +## The export macro — Rust machines + +- The macro takes the class name, the machine type, a constructor body, and the derived + values the class serves, each with its key and its type. A constructor body that + returns an error throws it from the JS constructor; bad arguments read as + `[machine] bad argument: ...`. +- The class implements the protocol: attach to a host (once), dispatch by kind, start, + stop, report whether it runs, report the state, read a context field, read a derived + value, take a due timer, and report the names (states, event types, fields, derived + values, tags per state) the facade maps the numbers onto. +- The facade argument is optional, so the class works on its own: `start()`, + `send(kind, event)`. Once attached, a call without its facade is an engine failure, + since the host needs the facade for every callback. +- An event kind without data is built from the number alone; any other kind is read from + the JS event's fields. The event type opts in to that with `#[event(deserialize)]`. +- The crate using the macro depends on `wasm-bindgen` at the version this crate pins. + +## Edge cases that carry design meaning + +- Attaching a Rust machine to a host twice is an engine failure. +- An unknown event kind is an error; the facade filters unknown event types before. +- A malformed payload is an error that names the event type. +- Commands emitted before a Rust machine was attached run when it attaches. diff --git a/crates/wasm/src/bridge.rs b/crates/wasm/src/bridge.rs new file mode 100644 index 0000000..79997bd --- /dev/null +++ b/crates/wasm/src/bridge.rs @@ -0,0 +1,212 @@ +//! What every machine exported to JS shares, TS-authored (the runtime) or Rust +//! (`export_machine!`): the JS host it calls back into, the failure flag of a call, and +//! the host clock that runs its timers. +//! +//! The host is one JS object per module. Its functions take numbers and return status +//! codes, so a call crosses the boundary once, with nothing to box or free. JS passes the +//! machine's facade with each call into Rust, and Rust hands it to every host function +//! of that call, then lets it go: Rust holds no JS object between calls, so no +//! JS <-> wasm reference cycle keeps a machine alive. +//! +//! JS owns the errors: a host function catches what user code throws, keeps it, and +//! returns a failure status. Rust only stops the step and reports the status back. + +use std::cell::{Cell, OnceCell, Ref, RefCell}; +use std::rc::Rc; + +use dunky_core::{Changes, Command, Machine, Types}; +use wasm_bindgen::prelude::*; + +/// A call finished. +pub const OK: u32 = 0; +/// User code failed during the call; the JS side holds the error. +pub const FAILED: u32 = 1; +/// The engine failed (a feedback loop, a malformed event): read `takeFailure()`. +pub const ENGINE_FAILED: u32 = 3; + +#[wasm_bindgen] +extern "C" { + /// The JS host object. + #[derive(Clone)] + pub type HostObject; + + /// Once per effective change. Returns 0, or 1 when an observer threw. + #[wasm_bindgen(method)] + fn notify(this: &HostObject, facade: &JsValue, state: u32, lo: u32, hi: u32) -> u32; + + /// Returns the host's timeout. + #[wasm_bindgen(method, js_name = startTimer)] + fn start_timer(this: &HostObject, facade: &JsValue, id: u32, ms: u32) -> JsValue; + + #[wasm_bindgen(method, js_name = cancelTimer)] + fn cancel_timer(this: &HostObject, timeout: &JsValue); +} + +/// One machine's line to the JS host. +pub(crate) struct Link { + pub(crate) host: HostObject, + /// The facade of the call in progress; `undefined` between calls. + facade: RefCell, + /// The machine's halt flag: a host function failed during the current call. + failed: Rc>, +} + +impl Link { + pub(crate) fn new(host: HostObject, halt: Rc>) -> Self { + Self { + host, + facade: RefCell::new(JsValue::UNDEFINED), + failed: halt, + } + } + + /// What every host function of the call in progress receives. + pub(crate) fn facade(&self) -> Ref<'_, JsValue> { + self.facade.borrow() + } + + pub(crate) fn fail(&self) { + self.failed.set(true); + } + + pub(crate) fn failed(&self) -> bool { + self.failed.get() + } + + pub(crate) fn notify(&self, state: u32, changes: Changes) { + let (lo, hi) = (changes.fields as u32, (changes.fields >> 32) as u32); + if self.host.notify(&self.facade(), state, lo, hi) != OK { + self.fail(); + } + } +} + +/// A machine exported to JS: runs each call to completion, then the timer commands, and +/// reports a status. +pub struct Bridge { + machine: Machine, + link: OnceCell>, + /// Pending timers: the engine's timer id, and the host's timeout. + timers: RefCell>, + /// Nesting of calls (JS code calls back in); the outermost one runs the timers. + depth: Cell, + /// The engine's own failure, until JS takes it. + failure: RefCell>, +} + +impl Bridge { + pub fn new(machine: Machine) -> Self { + Self { + machine, + link: OnceCell::new(), + timers: RefCell::default(), + depth: Cell::new(0), + failure: RefCell::new(None), + } + } + + pub fn machine(&self) -> &Machine { + &self.machine + } + + /// Connect to the JS host. Returns false when already connected. + pub(crate) fn connect(&self, link: Rc) -> bool { + self.link.set(link).is_ok() + } + + pub(crate) fn attached(&self) -> bool { + self.link.get().is_some() + } + + /// Run `f` on the machine and report a status. `facade` is the JS facade calling; + /// the outermost call keeps it for the host functions, runs the timer commands, then + /// lets it go. `FAILED` means user code failed during this very call: a nested call + /// (JS calling back in) does not report a failure from before it. + pub fn call(&self, facade: JsValue, f: impl FnOnce(&Machine)) -> u32 { + let depth = self.depth.get(); + let link = self.link.get(); + if depth == 0 + && let Some(link) = link + { + *link.facade.borrow_mut() = facade; + } + let failed_before = link.is_some_and(|l| l.failed()); + self.depth.set(depth + 1); + f(&self.machine); + self.depth.set(depth); + let failed = link.is_some_and(|l| l.failed()); + if depth > 0 { + return if failed && !failed_before { FAILED } else { OK }; + } + if let Some(link) = link { + self.run_timers(link); + *link.facade.borrow_mut() = JsValue::UNDEFINED; + // The flag is the machine's: the engine raises it for its own failure too. + let halted = link.failed.replace(false); + if let Some(message) = self.machine.take_failure() { + return self.fail(message); + } + if halted { + return FAILED; + } + } + match self.machine.take_failure() { + Some(message) => self.fail(message), + None => OK, + } + } + + /// Keep the engine's `message` for `take_failure`. + pub fn fail(&self, message: String) -> u32 { + *self.failure.borrow_mut() = Some(message); + ENGINE_FAILED + } + + pub fn take_failure(&self) -> Option { + self.failure.borrow_mut().take() + } + + pub fn start(&self, facade: JsValue) -> u32 { + self.call(facade, |m| m.start()) + } + + pub fn stop(&self, facade: JsValue) -> u32 { + self.call(facade, |m| m.stop()) + } + + /// The host clock: timer `id` came due. + pub fn fire(&self, id: u32, facade: JsValue) -> u32 { + { + let mut timers = self.timers.borrow_mut(); + if let Some(pos) = timers.iter().position(|(t, _)| *t == id) { + timers.swap_remove(pos); + } + } + self.call(facade, |m| m.fire_timer(id)) + } + + /// The core emits timer commands; the host runs them on its clock (`setTimeout`). + pub(crate) fn run_timers(&self, link: &Link) { + if !self.machine.has_commands() { + return; + } + for command in self.machine.take_commands() { + match command { + Command::StartTimer { id, ms } => { + let timeout = link.host.start_timer(&link.facade(), id, ms); + self.timers.borrow_mut().push((id, timeout)); + } + Command::CancelTimer { id } => { + let timeout = { + let mut timers = self.timers.borrow_mut(); + let pos = timers.iter().position(|(t, _)| *t == id); + pos.map(|pos| timers.swap_remove(pos).1) + }; + if let Some(timeout) = timeout { + link.host.cancel_timer(&timeout); + } + } + } + } + } +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 9fb406e..f573829 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -1,32 +1,40 @@ -//! `dunky-wasm` — export a `dunky-core` machine to JavaScript. +//! `dunky-wasm` — run `dunky-core` machines from JavaScript. //! -//! One macro, [`export_machine!`], turns a machine type into a `#[wasm_bindgen]` class that -//! speaks the handle protocol the TS adapter (`@dunky.dev/state-machine-wasm`) consumes: +//! - [`export_machine!`] turns a machine written in Rust into a JS class. The core package +//! (`@dunky.dev/state-machine`) wraps an instance with `fromWasm`, so every target — +//! react, solid, native, opentui — consumes it as a `Machine`. +//! - The `runtime` feature is the engine for machines written in TS: the core package +//! ships it (`crates/core-wasm`). //! -//! - **Event in**: `sendKind(kind)` for payload-less events (one number across the -//! boundary), `sendPayload(kind, { ...fields })` for the rest (only the payload fields -//! are read), `sendEvent({ type, ... })` as the generic serde path. -//! - **Changes out**: every mutating call returns a `u32` change mask (see [`STATE_BIT`], -//! [`COMMANDS_BIT`], [`FIELD_SHIFT`]), so JS re-reads only the fields that changed. -//! - **Commands out**: `takeCommands()` returns the timer commands as a flat `Uint32Array` -//! (`[op, id, ms, …]`, op 1 = start, 2 = cancel); JS runs them with `setTimeout` and -//! calls `fireTimer(id)`. +//! Both speak one protocol with the core package's JS host: events in by kind number, +//! a `notify(handle, state, changedLo, changedHi)` per effective change (synchronously, +//! like the TS subscribers), timers on the host clock (`startTimer` / `cancelTimer`, then +//! `fire(id)`), and a status code back from every call (see [`bridge`]). //! //! The crate using the macro must also depend on `wasm-bindgen`. +use std::cell::RefCell; +use std::rc::Rc; + use dunky_core::{ - Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, Types, + Changes, Cleanup, ComputedKey, ComputedParams, Context, DeserializeEvent, EventEnum, Host, + Machine, SerializeFields, StateEnum, Types, }; use serde::Serialize; use serde::de::DeserializeOwned; +use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; -// The protocol layout is shared with every binding (see `dunky_core::protocol`). -pub use dunky_core::protocol::{COMMANDS_BIT, FIELD_SHIFT, HIGH_FIELD, STATE_BIT}; +pub mod bridge; +#[cfg(feature = "runtime")] +pub mod runtime; + +use bridge::Link; +pub use bridge::{Bridge, ENGINE_FAILED, FAILED, HostObject, OK}; #[doc(hidden)] pub mod __private { - pub use js_sys; + pub use dunky_core::ComputedKey; } /// What an [`export_machine!`] constructor body may evaluate to: a machine, or a @@ -49,12 +57,13 @@ impl IntoMachine for Result, JsError> { /// Deserialize a JS value (constructor arguments, props). pub fn from_js(value: JsValue) -> Result { - serde_wasm_bindgen::from_value(value).map_err(|e| JsError::new(&e.to_string())) -} - -/// The protocol implementation behind every exported class. -pub struct Handle { - pub machine: Machine, + serde_wasm_bindgen::from_value(value).map_err(|e| { + let error: js_sys::Error = JsValue::from(e).unchecked_into(); + JsError::new(&format!( + "[machine] bad argument: {}", + String::from(error.message()) + )) + }) } // `None` becomes `null`, matching the TS machines (`lastExecuted: null`, `activeId: null`). @@ -62,162 +71,205 @@ fn serializer() -> serde_wasm_bindgen::Serializer { serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true) } -/// Serialize any value for JS (plain objects, arrays, `undefined` for `None`). +/// Serialize any value for JS (plain objects, arrays, `null` for `None`). pub fn to_js(value: &V) -> JsValue { value.serialize(&serializer()).unwrap_or(JsValue::UNDEFINED) } -impl Handle +/// A Rust machine's host: it only hears changes (its guards, actions and effects are Rust). +struct Observer { + link: Rc, +} + +impl Host for Observer { + fn guard(&self, _: u32, _: Option<&T::Event>) -> bool { + unreachable!("a Rust machine has no external guards") + } + fn action(&self, _: u32, _: Option<&T::Event>) { + unreachable!("a Rust machine has no external actions") + } + fn effect(&self, _: u32, _: Option<&T::Event>) -> Option { + unreachable!("a Rust machine has no external effects") + } + fn delay(&self, _: u32, _: Option<&T::Event>) -> u32 { + unreachable!("a Rust machine has no external delays") + } + fn computed(&self, _: u32, _: &ComputedParams<'_, T>) -> Rc { + unreachable!("a Rust machine has no external computed values") + } + fn pick(&self, _: u32, _: Option<&T::Event>) -> Option { + unreachable!("a Rust machine has no external guards") + } + fn actions(&self, _: u32, _: Option<&T::Event>) { + unreachable!("a Rust machine has no external actions") + } + fn notify(&self, state: T::State, changes: Changes) { + self.link.notify(state.index() as u32, changes); + } + fn settle(&self) {} +} + +/// The implementation behind every class [`export_machine!`] generates. +pub struct Exported { + bridge: Bridge, + /// Per computed value: the version and the JS value last served. + computed: RefCell>>, +} + +impl Exported where - T::Event: DeserializeOwned + DeserializeEvent, + T::Event: DeserializeEvent, T::Context: SerializeFields, { pub fn new(machine: Machine) -> Self { - Self { machine } + let count = machine.config().computed_names().count(); + Self { + bridge: Bridge::new(machine), + computed: RefCell::new(vec![None; count]), + } } - /// Pack what changed since the last call into the change mask. - pub fn changes(&self) -> u32 { - dunky_core::protocol::take_change_mask(&self.machine) + pub fn machine(&self) -> &Machine { + self.bridge.machine() } - pub fn send_kind(&self, kind: u32) -> Result { - let names = ::KIND_NAMES; - if kind as usize >= names.len() { - return Err(JsError::new(&format!("[machine] no event type #{kind}"))); - } - let k = ::kind_from_index(kind as usize); - match ::from_kind(k) { - Some(event) => { - self.machine.send(event); - Ok(self.changes()) - } - None => Err(JsError::new(&format!( - "[machine] event \"{}\" carries data; use sendEvent", - names[kind as usize] - ))), + /// Connect to the JS host; `facade` is the JS facade calling. Returns a status; a + /// second attach is an engine failure. + pub fn attach(&self, host: HostObject, facade: JsValue) -> u32 { + let link = Rc::new(Link::new(host, self.machine().halt_flag())); + if !self.bridge.connect(link.clone()) { + return self.bridge.fail("[machine] already attached".into()); } + self.machine().set_host(Rc::new(Observer { link })); + // Run what the machine did before it had a host (e.g. timers of a start in Rust). + self.bridge.call(facade, |_| {}) } - pub fn send_event(&self, event: JsValue) -> Result { - let event: T::Event = serde_wasm_bindgen::from_value(event) - .map_err(|e| JsError::new(&format!("[machine] bad event: {e}")))?; - self.machine.send(event); - Ok(self.changes()) + /// The facade a call runs for. `fromWasm` passes it; a bare call has none, which is + /// fine until a host is attached: then the host needs it for every callback. + fn facade(&self, facade: Option) -> Result { + match facade { + Some(facade) => Ok(facade), + None if self.bridge.attached() => Err(self.bridge.fail( + "[machine] this machine is attached: call it through its fromWasm machine".into(), + )), + None => Ok(JsValue::UNDEFINED), + } } - /// The fast path for events with a payload: the caller already knows the kind, so - /// only the payload fields are read from the object (no tagged-enum buffering). - pub fn send_payload(&self, kind: u32, event: JsValue) -> Result { + /// Send event `kind`; `event` (the JS event object) supplies the payload, if any. + pub fn send(&self, kind: u32, event: JsValue, facade: Option) -> u32 { + let facade = match self.facade(facade) { + Ok(facade) => facade, + Err(status) => return status, + }; let names = ::KIND_NAMES; - if kind as usize >= names.len() { - return Err(JsError::new(&format!("[machine] no event type #{kind}"))); - } - let deserializer = serde_wasm_bindgen::Deserializer::from(event); - let event = - ::deserialize_payload(kind as usize, deserializer) - .map_err(|e| { - JsError::new(&format!( - "[machine] bad \"{}\" event: {e}", - names[kind as usize] - )) - })?; - self.machine.send(event); - Ok(self.changes()) + let Some(name) = names.get(kind as usize) else { + return self.bridge.fail(format!("[machine] no event type #{kind}")); + }; + let k = ::kind_from_index(kind as usize); + let event = match ::from_kind(k) { + Some(event) => event, + None => { + let deserializer = serde_wasm_bindgen::Deserializer::from(event); + match ::deserialize_payload( + kind as usize, + deserializer, + ) { + Ok(event) => event, + Err(e) => { + return self + .bridge + .fail(format!("[machine] bad \"{name}\" event: {e}")); + } + } + } + }; + self.bridge.call(facade, |m| m.send(event)) } - pub fn state_index(&self) -> u32 { - self.machine.state().index() as u32 + pub fn start(&self, facade: Option) -> u32 { + self.facade(facade) + .map_or_else(|status| status, |facade| self.bridge.start(facade)) } - pub fn field(&self, index: u32) -> JsValue { - let ctx = self.machine.context(); - ctx.serialize_field(index as usize, &serializer()) - .unwrap_or(JsValue::UNDEFINED) + pub fn stop(&self, facade: Option) -> u32 { + self.facade(facade) + .map_or_else(|status| status, |facade| self.bridge.stop(facade)) } - /// Every field, in order (the initial mirror). - pub fn fields(&self) -> js_sys::Array { - let ctx = self.machine.context(); - let out = js_sys::Array::new(); - for i in 0..::FIELDS.len() { - out.push( - &ctx.serialize_field(i, &serializer()) - .unwrap_or(JsValue::UNDEFINED), - ); - } - out + pub fn fire(&self, id: u32, facade: Option) -> u32 { + self.facade(facade) + .map_or_else(|status| status, |facade| self.bridge.fire(id, facade)) } - pub fn computed_version(&self, index: u32) -> f64 { - self.machine.computed_version(index as usize) as f64 + /// The engine's failure behind status 3. + pub fn take_failure(&self) -> Option { + self.bridge.take_failure() } - pub fn start(&self) -> u32 { - self.machine.start(); - self.changes() + pub fn running(&self) -> bool { + self.machine().is_running() } - pub fn stop(&self) -> u32 { - self.machine.stop(); - self.changes() + pub fn state(&self) -> u32 { + self.machine().state().index() as u32 } - pub fn fire_timer(&self, id: u32) -> u32 { - self.machine.fire_timer(id); - self.changes() + pub fn field(&self, index: u32) -> JsValue { + self.machine() + .context() + .serialize_field(index as usize, &serializer()) + .unwrap_or(JsValue::UNDEFINED) } - pub fn take_commands(&self) -> Vec { - dunky_core::protocol::encode_commands(&self.machine.take_commands()) + /// The value of `key`, serialized only when it changed since the last read. + pub fn computed(&self, key: ComputedKey) -> JsValue { + let version = self.machine().computed_version(key.id()); + if let Some((seen, value)) = &self.computed.borrow()[key.id()] + && *seen == version + { + return value.clone(); + } + let value = to_js(&*self.machine().computed(key)); + self.computed.borrow_mut()[key.id()] = Some((version, value.clone())); + value } - /// Static description for the adapter: names, which events are payload-less, tags. + /// The names `fromWasm` maps the numbers onto, once per machine type. pub fn meta(&self) -> JsValue { #[derive(Serialize)] - #[serde(rename_all = "camelCase")] struct Meta<'a> { states: &'a [&'a str], events: &'a [&'a str], - unit_events: Vec, fields: &'a [&'a str], computed: Vec<&'a str>, tags: Vec<&'a [&'a str]>, - field_shift: u32, - high_field: usize, } - let config = self.machine.config(); - let events = ::KIND_NAMES; + let config = self.machine().config(); let states = ::NAMES; to_js(&Meta { states, - events, - unit_events: (0..events.len()) - .map(|i| { - let k = ::kind_from_index(i); - ::from_kind(k).is_some() - }) - .collect(), + events: ::KIND_NAMES, fields: ::FIELDS, computed: config.computed_names().collect(), tags: (0..states.len()) .map(|i| config.tags(::from_index(i))) .collect(), - field_shift: FIELD_SHIFT, - high_field: HIGH_FIELD, }) } } -/// Export a machine type as a JS class. +/// Export a machine type as a JS class. Wrap an instance with `fromWasm` from +/// `@dunky.dev/state-machine`. /// /// ```ignore /// dunky_wasm::export_machine! { /// /// The command palette. -/// pub struct PaletteMachine(dunky_demo::palette::Palette); +/// pub struct PaletteMachine(palette::Palette); /// new(commands: JsValue) { /// // A `Result` body throws its error from the JS constructor. -/// dunky_wasm::from_js(commands).map(|c| Machine::new(&dunky_demo::palette::config(c))) +/// dunky_wasm::from_js(commands).map(|c| Machine::new(&palette::config(c))) /// } /// computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } /// } @@ -233,7 +285,7 @@ macro_rules! export_machine { $(#[$meta])* #[::wasm_bindgen::prelude::wasm_bindgen] $vis struct $name { - handle: $crate::Handle<$types>, + inner: $crate::Exported<$types>, } #[::wasm_bindgen::prelude::wasm_bindgen] @@ -243,63 +295,51 @@ macro_rules! export_machine { #[allow(unused_braces)] // `$make` is the caller's block, passed as an argument pub fn new($($arg: $argty),*) -> ::core::result::Result<$name, ::wasm_bindgen::JsError> { let machine = $crate::IntoMachine::<$types>::into_machine($make)?; - ::core::result::Result::Ok($name { handle: $crate::Handle::new(machine) }) + ::core::result::Result::Ok($name { inner: $crate::Exported::new(machine) }) } - /// Payload-less event by kind index. Returns the change mask. - #[wasm_bindgen(js_name = sendKind)] - pub fn send_kind(&self, kind: u32) -> ::core::result::Result { - self.handle.send_kind(kind) + /// Connect to the JS host (`fromWasm` does). Calls return a status: 0, 1 when + /// JS code failed (the host holds the error), 3 when the engine failed. + pub fn attach(&self, host: $crate::HostObject, facade: ::wasm_bindgen::JsValue) -> u32 { + self.inner.attach(host, facade) } - /// Any event object `{ type, ...payload }`. Returns the change mask. - #[wasm_bindgen(js_name = sendEvent)] - pub fn send_event(&self, event: ::wasm_bindgen::JsValue) -> ::core::result::Result { - self.handle.send_event(event) + /// `facade` is passed by `fromWasm`; leave it out when calling directly. + pub fn send(&self, kind: u32, event: ::wasm_bindgen::JsValue, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { + self.inner.send(kind, event, facade) } - /// An event with a payload, by kind index. Returns the change mask. - #[wasm_bindgen(js_name = sendPayload)] - pub fn send_payload(&self, kind: u32, event: ::wasm_bindgen::JsValue) -> ::core::result::Result { - self.handle.send_payload(kind, event) + pub fn start(&self, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { + self.inner.start(facade) } - #[wasm_bindgen(js_name = stateIndex)] - pub fn state_index(&self) -> u32 { - self.handle.state_index() + pub fn stop(&self, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { + self.inner.stop(facade) } - pub fn field(&self, index: u32) -> ::wasm_bindgen::JsValue { - self.handle.field(index) + pub fn fire(&self, id: u32, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { + self.inner.fire(id, facade) + } + #[wasm_bindgen(js_name = takeFailure)] + pub fn take_failure(&self) -> ::core::option::Option<::std::string::String> { + self.inner.take_failure() } - pub fn fields(&self) -> $crate::__private::js_sys::Array { - self.handle.fields() + pub fn running(&self) -> bool { + self.inner.running() + } + pub fn state(&self) -> u32 { + self.inner.state() + } + pub fn field(&self, index: u32) -> ::wasm_bindgen::JsValue { + self.inner.field(index) } #[allow(unused_variables)] - pub fn computed(&self, index: u32) -> ::wasm_bindgen::JsValue { + pub fn computed(&self, id: u32) -> ::wasm_bindgen::JsValue { $( - if index as usize == ($key).id() { - let value: ::std::rc::Rc<$cty> = self.handle.machine.computed($key); - return $crate::to_js(&*value); + let key: $crate::__private::ComputedKey<$cty> = $key; + if id as usize == key.id() { + return self.inner.computed(key); } )* ::wasm_bindgen::JsValue::UNDEFINED } - #[wasm_bindgen(js_name = computedVersion)] - pub fn computed_version(&self, index: u32) -> f64 { - self.handle.computed_version(index) - } - pub fn start(&self) -> u32 { - self.handle.start() - } - pub fn stop(&self) -> u32 { - self.handle.stop() - } - #[wasm_bindgen(js_name = fireTimer)] - pub fn fire_timer(&self, id: u32) -> u32 { - self.handle.fire_timer(id) - } - #[wasm_bindgen(js_name = takeCommands)] - pub fn take_commands(&self) -> ::std::vec::Vec { - self.handle.take_commands() - } pub fn meta(&self) -> ::wasm_bindgen::JsValue { - self.handle.meta() + self.inner.meta() } } }; diff --git a/crates/wasm/src/runtime.rs b/crates/wasm/src/runtime.rs new file mode 100644 index 0000000..530dcfd --- /dev/null +++ b/crates/wasm/src/runtime.rs @@ -0,0 +1,603 @@ +//! The runtime for TS-authored machines (cargo feature `runtime`). +//! +//! The Rust engine runs the graph: queue, transition resolution, entry/exit order, +//! effects lifecycle, `after` timers, watchers and computed bookkeeping. JS runs the +//! user code — guards, actions, effects, delays, computed definitions — through the +//! host functions (see `crate::bridge`), by the callback's number in the JS-side tables. +//! +//! The context lives in JS (it holds arbitrary JS values). JS reports which fields a +//! write changed (`markChanged`) and which fields a computed value read (`reportDeps`); +//! Rust keeps the change stamps that drive watchers and computed staleness. + +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use dunky_core::{ + Action, Branch, Changes, Cleanup, ComputedParams, Config, Context, Delay, Effect, EventEnum, + Field, Guard, Host, Machine, StateEnum, TransitionBuilder, Types, +}; +use serde::Deserialize; +use wasm_bindgen::prelude::*; + +use crate::bridge::{Bridge, HostObject, Link, OK}; + +// --------------------------------------------------------------------------------- +// The machine type of every TS-authored machine: states and event types are numbers +// assigned by the JS compiler; the context is owned by JS. + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct DynState(pub u32); + +impl StateEnum for DynState { + // Names live in JS; the engine only needs indices. + const NAMES: &'static [&'static str] = &[]; + fn index(self) -> usize { + self.0 as usize + } + fn from_index(index: usize) -> Self { + DynState(index as u32) + } +} + +/// An event object sent from JS, with its type number. +#[derive(Clone, Debug)] +pub struct JsEvent { + kind: u32, + value: JsValue, +} + +impl EventEnum for JsEvent { + type Kind = u32; + const KIND_NAMES: &'static [&'static str] = &[]; + fn kind(&self) -> u32 { + self.kind + } + fn kind_index(kind: u32) -> usize { + kind as usize + } + fn kind_from_index(index: usize) -> u32 { + index as u32 + } + fn from_kind(_kind: u32) -> Option { + None + } +} + +/// The context of a TS machine lives in JS. Its patch is the change mask JS computed. +#[derive(Clone, Default)] +pub struct JsContext; + +/// Records the fields a computed value read, as reported by JS. +#[derive(Clone, Copy)] +pub struct JsReader<'a> { + deps: &'a Cell, +} + +impl JsReader<'_> { + fn mark(&self, fields: u64) { + self.deps.set(self.deps.get() | fields); + } +} + +impl Context for JsContext { + type Patch = u64; + type Reader<'a> = JsReader<'a>; + const FIELDS: &'static [&'static str] = &[]; + fn apply(&mut self, changed: u64) -> u64 { + changed + } + fn reader<'a>(&'a self, deps: &'a Cell) -> JsReader<'a> { + JsReader { deps } + } +} + +pub struct Js; + +impl Types for Js { + type State = DynState; + type Event = JsEvent; + type Context = JsContext; +} + +// --------------------------------------------------------------------------------- +// The compiled config the JS side sends. Every callback is a number into the JS-side +// tables of that config. + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Spec { + initial: u32, + kinds: u32, + states: Vec, + #[serde(default)] + on_any: Vec, + #[serde(default)] + computed: u32, + #[serde(default)] + watch: Vec, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct StateSpec { + on: Vec, + entry: Vec, + exit: Vec, + effects: Vec, + after: Vec, +} + +/// The handler for one event type: candidates in order. With `pick`, the host evaluates +/// their guards in one call (its guard list `pick`). +#[derive(Deserialize)] +struct EntrySpec { + kind: u32, + candidates: Vec, + #[serde(default)] + pick: Option, +} + +#[derive(Deserialize)] +struct AfterSpec { + delay: DelaySpec, + candidates: Vec, + #[serde(default)] + pick: Option, +} + +#[derive(Deserialize)] +struct CandidateSpec { + #[serde(default)] + target: Option, + #[serde(default)] + guard: Option, + #[serde(default)] + actions: Vec, +} + +/// `{ id }` for a callback, `{ list }` for a run of callbacks the host runs in one call, +/// `{ oneOf: [...] }` for a conditional. +#[derive(Deserialize)] +struct ActionSpec { + #[serde(default)] + id: Option, + #[serde(default)] + list: Option, + #[serde(default, rename = "oneOf")] + one_of: Option>, +} + +#[derive(Deserialize)] +struct BranchSpec { + #[serde(default)] + guard: Option, + #[serde(default)] + actions: Vec, +} + +/// `{ ms }` for a fixed delay, `{ id }` for a named one. +#[derive(Deserialize)] +struct DelaySpec { + #[serde(default)] + ms: Option, + #[serde(default)] + id: Option, +} + +#[derive(Deserialize)] +struct WatchSpec { + #[serde(default)] + field: Option, + #[serde(default)] + computed: Option, + actions: Vec, +} + +fn action(spec: &ActionSpec) -> Action { + if let Some(branches) = &spec.one_of { + return Action::one_of(branches.iter().map(|b| { + let actions = b.actions.iter().map(action); + match b.guard { + Some(guard) => Branch::when(Guard::External(guard), actions), + None => Branch::otherwise(actions), + } + })); + } + match (spec.list, spec.id) { + (Some(list), _) => Action::ExternalList(list), + (None, Some(id)) => Action::External(id), + (None, None) => Action::one_of([]), + } +} + +fn candidate(mut t: TransitionBuilder, spec: &CandidateSpec) -> TransitionBuilder { + if let Some(target) = spec.target { + t = t.target(DynState(target)); + } + if let Some(guard) = spec.guard { + t = t.guard_expr(Guard::External(guard)); + } + for a in &spec.actions { + t = t.push(action(a)); + } + t +} + +/// Computed values of a TS machine compare like the TS engine: `Object.is`. +fn js_is(a: &dyn Any, b: &dyn Any) -> bool { + match (a.downcast_ref::(), b.downcast_ref::()) { + (Some(a), Some(b)) => js_sys::Object::is(a, b), + _ => false, + } +} + +fn build(spec: &Spec) -> Config { + let mut b = Config::::builder_sized( + DynState(spec.initial), + JsContext, + spec.states.len(), + spec.kinds as usize, + ); + for _ in 0..spec.computed { + b.computed_external("", js_is); + } + for (index, state) in spec.states.iter().enumerate() { + b.state(DynState(index as u32), |mut s| { + for entry in &state.on { + for c in &entry.candidates { + s = s.on(entry.kind, |t| candidate(t, c)); + } + if let Some(list) = entry.pick { + s = s.pick(entry.kind, list); + } + } + for a in &state.entry { + s = s.entry(action(a)); + } + for a in &state.exit { + s = s.exit(action(a)); + } + for id in &state.effects { + s = s.effect(Effect::External(*id)); + } + for after in &state.after { + let delay = match (after.delay.ms, after.delay.id) { + (_, Some(id)) => Delay::External(id), + (ms, None) => Delay::Ms(ms.unwrap_or(0)), + }; + let mut rest = after.candidates.iter(); + if let Some(first) = rest.next() { + s = s.after(delay, |t| candidate(t, first)); + for c in rest { + s = s.after_or(|t| candidate(t, c)); + } + if let Some(list) = after.pick { + s = s.after_pick(list); + } + } + } + s + }); + } + for entry in &spec.on_any { + for c in &entry.candidates { + b.on(entry.kind, |t| candidate(t, c)); + } + if let Some(list) = entry.pick { + b.pick_any(entry.kind, list); + } + } + for w in &spec.watch { + let actions = w.actions.iter().map(action).collect::>(); + match (w.field, w.computed) { + (Some(field), _) => { + b.watch(Field::new(field.min(63) as usize), actions); + } + (None, Some(id)) => { + b.watch_computed_id(id as usize, actions); + } + (None, None) => {} + } + } + b.build() +} + +// --------------------------------------------------------------------------------- +// The host: the JS functions run the user code by its index in the JS tables, and +// return status codes (they catch what user code throws and keep it). + +#[wasm_bindgen] +extern "C" { + /// 0 false, 1 true, 2 failed. + #[wasm_bindgen(method)] + fn guard(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> u32; + /// 0, or 1 when it failed. `notify` is a state change to deliver first, or -1. + #[wasm_bindgen(method)] + fn action(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, notify: i32) -> u32; + /// The cleanup's id, 0 for no cleanup, or -1 when it failed. `notify` as for `action`. + #[wasm_bindgen(method)] + fn effect(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, notify: i32) -> i32; + /// 0, or 1 when it failed. + #[wasm_bindgen(method)] + fn cleanup(this: &HostObject, facade: &JsValue, id: u32) -> u32; + /// The delay in ms, or -1 when it failed. + #[wasm_bindgen(method)] + fn delay(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> f64; + /// 0 once the value is reported (`JsMachine::report`), or 1 when it failed. + #[wasm_bindgen(method)] + fn computed(this: &HostObject, facade: &JsValue, id: u32) -> u32; + /// The index of the first candidate of guard list `list` that passes, -1 for none, + /// -2 when a guard failed. + #[wasm_bindgen(method)] + fn pick(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue) -> i32; + /// Run action list `list` in order. 0, or 1 when an action failed. `notify` as for + /// `action`. + #[wasm_bindgen(method)] + fn actions(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue, notify: i32) + -> u32; +} + +/// What one computed evaluation returned and read, reported by JS before it returns. +struct Report { + value: JsValue, + fields: u64, + state: bool, + computed: Option>, +} + +struct JsHost { + link: Rc, + report: RefCell>, + /// A state change held back to ride on the next action or effect call (one crossing + /// instead of two); -1 for none. Delivered first, or at `settle`. + pending: Cell, +} + +impl JsHost { + /// The held-back state change, for a call that delivers it first. + fn take_pending(&self) -> i32 { + self.pending.replace(-1) + } + + /// Deliver the held-back state change now. + fn deliver(&self) { + let state = self.take_pending(); + if state >= 0 { + self.link.notify(state as u32, Changes::default()); + } + } +} + +/// Run `f` with the event's JS value (`undefined` for a boot or a data-reaction). +fn with_event(event: Option<&JsEvent>, f: impl FnOnce(&JsValue) -> R) -> R { + match event { + Some(e) => f(&e.value), + None => f(&JsValue::UNDEFINED), + } +} + +impl Host for JsHost { + fn guard(&self, id: u32, event: Option<&JsEvent>) -> bool { + self.deliver(); + match with_event(event, |e| self.link.host.guard(&self.link.facade(), id, e)) { + 0 => false, + 1 => true, + _ => { + self.link.fail(); + false + } + } + } + + fn action(&self, id: u32, event: Option<&JsEvent>) { + let notify = self.take_pending(); + if with_event(event, |e| { + self.link.host.action(&self.link.facade(), id, e, notify) + }) != OK + { + self.link.fail(); + } + } + + fn effect(&self, id: u32, event: Option<&JsEvent>) -> Option { + let notify = self.take_pending(); + let cleanup = with_event(event, |e| { + self.link.host.effect(&self.link.facade(), id, e, notify) + }); + if cleanup < 0 { + self.link.fail(); + } + if cleanup <= 0 { + return None; + } + let link = self.link.clone(); + Some(Box::new(move || { + if link.host.cleanup(&link.facade(), cleanup as u32) != OK { + link.fail(); + } + })) + } + + fn delay(&self, id: u32, event: Option<&JsEvent>) -> u32 { + self.deliver(); + let ms = with_event(event, |e| self.link.host.delay(&self.link.facade(), id, e)); + if ms.is_nan() || ms < 0.0 { + self.link.fail(); + return 0; + } + ms as u32 + } + + fn computed(&self, id: u32, params: &ComputedParams<'_, Js>) -> Rc { + self.deliver(); + if self.link.host.computed(&self.link.facade(), id) != OK { + self.link.fail(); + } + let Some(report) = self.report.borrow_mut().take() else { + return Rc::new(JsValue::UNDEFINED); + }; + params.context.mark(report.fields); + if report.state { + params.state(); + } + for c in report.computed.into_iter().flatten() { + params.track_computed(c as usize); + } + Rc::new(report.value) + } + + fn pick(&self, list: u32, event: Option<&JsEvent>) -> Option { + self.deliver(); + match with_event(event, |e| self.link.host.pick(&self.link.facade(), list, e)) { + -2 => { + self.link.fail(); + None + } + picked => usize::try_from(picked).ok(), + } + } + + fn actions(&self, list: u32, event: Option<&JsEvent>) { + let notify = self.take_pending(); + if with_event(event, |e| { + self.link.host.actions(&self.link.facade(), list, e, notify) + }) != OK + { + self.link.fail(); + } + } + + fn notify(&self, state: DynState, changes: Changes) { + // Context writes are JS's to announce; a state change waits for the next call. + self.deliver(); + if changes.fields == 0 { + self.pending.set(state.0 as i32); + } else { + self.link.notify(state.0, changes); + } + } + + fn settle(&self) { + self.deliver(); + } +} + +// --------------------------------------------------------------------------------- +// The JS classes. + +/// A compiled TS config, shared by every machine built from it. +#[wasm_bindgen] +pub struct JsConfig { + config: Config, +} + +#[wasm_bindgen] +impl JsConfig { + #[wasm_bindgen(constructor)] + pub fn new(spec: JsValue) -> Result { + let spec: Spec = serde_wasm_bindgen::from_value(spec) + .map_err(|e| JsError::new(&format!("[machine] bad config: {e}")))?; + Ok(JsConfig { + config: build(&spec), + }) + } +} + +/// One TS-authored machine running on the Rust engine. Calls return a status: 0, 1 when +/// user code failed (the JS side holds the error), 3 when the engine failed +/// (`takeFailure`). +#[wasm_bindgen] +pub struct JsMachine { + bridge: Bridge, + host: Rc, +} + +#[wasm_bindgen] +impl JsMachine { + /// `host` is the JS host object. Each call takes the facade calling (`facade`), which + /// the host functions of that call receive. + #[wasm_bindgen(constructor)] + pub fn new(config: &JsConfig, host: HostObject) -> JsMachine { + let machine = Machine::new(&config.config); + let link = Rc::new(Link::new(host, machine.halt_flag())); + let bridge = Bridge::new(machine); + bridge.connect(link.clone()); + let host = Rc::new(JsHost { + link, + report: RefCell::new(None), + pending: Cell::new(-1), + }); + bridge.machine().set_host(host.clone()); + JsMachine { bridge, host } + } + + pub fn send(&self, kind: u32, event: JsValue, facade: JsValue) -> u32 { + self.bridge + .call(facade, |m| m.send(JsEvent { kind, value: event })) + } + + pub fn start(&self, facade: JsValue) -> u32 { + self.bridge.start(facade) + } + + pub fn stop(&self, facade: JsValue) -> u32 { + self.bridge.stop(facade) + } + + pub fn running(&self) -> bool { + self.bridge.machine().is_running() + } + + pub fn state(&self) -> u32 { + self.bridge.machine().state().0 + } + + /// JS changed context fields (`lo`/`hi`: the low and high 32 bits of the mask). Only + /// a machine with watchers or computed values needs to say so. + #[wasm_bindgen(js_name = markChanged)] + pub fn mark_changed(&self, lo: u32, hi: u32, facade: JsValue) -> u32 { + self.bridge.call(facade, |m| { + m.mark_changed((u64::from(hi) << 32) | u64::from(lo)); + }) + } + + /// The value of computed `id` (`undefined` when its definition failed). + pub fn computed(&self, id: u32, facade: JsValue) -> JsValue { + let mut value = JsValue::UNDEFINED; + self.bridge.call(facade, |m| { + if let Some(v) = m.computed_any(id as usize).downcast_ref::() { + value = v.clone(); + } + }); + value + } + + /// What the computed evaluation in progress returned and read. + pub fn report( + &self, + value: JsValue, + lo: u32, + hi: u32, + state: bool, + computed: Option>, + ) { + *self.host.report.borrow_mut() = Some(Report { + value, + fields: (u64::from(hi) << 32) | u64::from(lo), + state, + computed, + }); + } + + /// The host clock: timer `id` (from `startTimer`) came due. + pub fn fire(&self, id: u32, facade: JsValue) -> u32 { + self.bridge.fire(id, facade) + } + + /// The engine's failure behind status 3. + #[wasm_bindgen(js_name = takeFailure)] + pub fn take_failure(&self) -> Option { + self.bridge.take_failure() + } +} diff --git a/knip.config.ts b/knip.config.ts index 6a78922..2d4e5d5 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -11,10 +11,6 @@ const config: KnipConfig = { // `pnpm -C dev` is a workspace script name knip mistakes for a binary. ignoreBinaries: ['dev'], }, - 'packages/native-rust': { - // Imported by the generated (gitignored) uniffi bindings, which knip can't see. - ignoreDependencies: ['@ubjs/core'], - }, }, } diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts index 234d70b..435c2fc 100644 --- a/packages/core/src/actions.ts +++ b/packages/core/src/actions.ts @@ -1,6 +1,4 @@ -import { isDev } from './constants' -import { makeGuardParams, resolveGuard } from './guards' -import type { Action, ActionArg, Actions, ActionParams, Guard, OneOf, OneOfBranch } from './types' +import type { Action, ActionArg, ActionParams, OneOf, OneOfBranch } from './types' /** A context patch, or a function of the action params that returns one. */ export type Patch, Send = Event> = @@ -47,55 +45,3 @@ export function isOneOf( (action as { __oneOf?: boolean }).__oneOf === true ) } - -// `context` / `computed` are the live objects — their identity never changes, so no getter. -export interface ActionHost { - actions: Record> | undefined - guards: Record> | undefined - context: Context - computed: Computed - setContext: (patch: Partial) => void - send: (event: Event) => void -} - -export function runAction( - host: ActionHost, - action: ActionArg, - event: Event, -): void { - if (isOneOf(action)) { - const params = makeGuardParams(host.context, event, host.computed, host.guards) - for (const branch of action.branches) { - if (!branch.guard || resolveGuard(branch.guard, params, host.guards)) { - runActions(host, branch.actions, event) - return - } - } - return - } - const named = action as Exclude> - const fn = typeof named === 'function' ? named : host.actions?.[named] - if (!fn) { - const msg = `[machine] no action "${action as string}"` - if (isDev) throw new Error(msg) - console.warn(msg) - return - } - fn({ - context: host.context, - setContext: host.setContext, - event, - send: host.send, - computed: host.computed, - }) -} - -export function runActions( - host: ActionHost, - actions: Actions | undefined, - event: Event, -): void { - if (!actions) return - if (!Array.isArray(actions)) return runAction(host, actions, event) - for (const action of actions) runAction(host, action, event) -} diff --git a/packages/core/src/compile.ts b/packages/core/src/compile.ts new file mode 100644 index 0000000..545e4e9 --- /dev/null +++ b/packages/core/src/compile.ts @@ -0,0 +1,257 @@ +/** + * Compiles a TS config for the Rust engine: the graph becomes numbers (states, event + * types, targets), the user code becomes tables the host callbacks index into. The + * spec shape mirrors `crates/wasm/src/runtime.rs`. + */ +import { isOneOf } from './actions' +import { isDev } from './constants' +import { JsConfig, startEngine } from './wasm' +import type { + ActionArg, + Actions, + Guard, + GuardArg, + TransitionConfig, + TransitionEntry, +} from './types' + +// The engine is generic over user code; inside the compiler everything is erased. +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Fn = (params: any) => any +type AnyConfig = TransitionConfig +type AnyGuard = GuardArg +type AnyAction = ActionArg +type AnyActions = Actions +type AnyEntry = TransitionEntry +/* eslint-enable @typescript-eslint/no-explicit-any */ + +interface ActionSpec { + id?: number + /** A run of actions the host runs in one call: an index into `actionLists`. */ + list?: number + oneOf?: Array<{ guard?: number; actions: ActionSpec[] }> +} +interface CandidateSpec { + target?: number + guard?: number + actions: ActionSpec[] +} +interface EntrySpec { + kind: number + candidates: CandidateSpec[] + /** The host evaluates the guards in one call: an index into `guardLists`. */ + pick?: number +} + +/** A TS config compiled for the Rust engine. Cached per config object. */ +export interface Compiled { + rust: JsConfig + initial: number + stateNames: string[] + kinds: Map + tags: Array> + /** Context key → change-mask bit. Bit 63 is shared by every key past the 63rd. */ + fieldBits: Map + /** Whether the engine needs to hear about context writes (watchers, computed values). */ + tracksContext: boolean + computedIndex: Map + computedDefs: Fn[] + guards: Fn[] + /** Per pick list: each candidate's guard, -1 for none. */ + guardLists: number[][] + /** Per action list: the actions, in order. */ + actionLists: number[][] + actions: Fn[] + effects: Fn[] + delays: Fn[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + guardRegistry: Record> | undefined +} + +/** The change-mask bit of `key`, assigning the next one on first sight. */ +export function fieldBit(bits: Map, key: string): number { + let bit = bits.get(key) + if (bit === undefined) { + bit = Math.min(bits.size, 63) + bits.set(key, bit) + } + return bit +} + +// A missing named implementation fails when it runs: a throw in dev, a warning in prod. +function missing(kind: 'guard' | 'action' | 'effect' | 'delay', name: string): Fn { + return () => { + const msg = `[machine] no ${kind} "${name}"` + if (isDev) throw new Error(msg) + console.warn(msg) + return kind === 'guard' ? false : kind === 'delay' ? 0 : undefined + } +} + +// Each distinct function gets one id in its table. +function table(list: Fn[]): (fn: Fn) => number { + const ids = new Map() + return fn => { + let id = ids.get(fn) + if (id === undefined) { + id = list.push(fn) - 1 + ids.set(fn, id) + } + return id + } +} + +const cache = new WeakMap() + +export function compile(config: AnyConfig): Compiled { + const cached = cache.get(config) + if (cached) return cached + startEngine() + + const impl = config.implementations ?? {} + const stateNames = Object.keys(config.states) + const stateIndex = new Map(stateNames.map((name, i) => [name, i])) + const indexOf = (name: string): number => { + const i = stateIndex.get(name) + if (i === undefined) throw new Error(`[machine] no state "${name}"`) + return i + } + const kinds = new Map() + const kindOf = (type: string): number => { + let kind = kinds.get(type) + if (kind === undefined) { + kind = kinds.size + kinds.set(type, kind) + } + return kind + } + + const guards: Fn[] = [] + const actions: Fn[] = [] + const effects: Fn[] = [] + const delays: Fn[] = [] + const guardId = table(guards) + const actionId = table(actions) + const effectId = table(effects) + const delayId = table(delays) + + const guardRef = (g: AnyGuard): number => + guardId(typeof g === 'function' ? g : (impl.guards?.[g] ?? missing('guard', g))) + const actionRef = (a: AnyAction): ActionSpec => + isOneOf(a) + ? { + oneOf: a.branches.map(b => ({ + guard: b.guard === undefined ? undefined : guardRef(b.guard), + actions: actionList(b.actions), + })), + } + : { id: actionId(typeof a === 'function' ? a : (impl.actions?.[a] ?? missing('action', a))) } + // One host call per run of plain actions, instead of one per action. + const guardLists: number[][] = [] + const actionLists: number[][] = [] + const actionList = (list: AnyActions | undefined): ActionSpec[] => { + if (list === undefined) return [] + const specs: ActionSpec[] = [] + let run: number[] = [] + const flush = () => { + if (run.length === 1) specs.push({ id: run[0] }) + else if (run.length > 1) specs.push({ list: actionLists.push(run) - 1 }) + run = [] + } + for (const a of Array.isArray(list) ? list : [list]) { + const spec = actionRef(a) + if (spec.id === undefined) { + flush() + specs.push(spec) + } else run.push(spec.id) + } + flush() + return specs + } + // One host call per guard walk, when two or more candidates have a guard. + const entrySpec = (entry: AnyEntry): { candidates: CandidateSpec[]; pick?: number } => { + const candidates: CandidateSpec[] = (Array.isArray(entry) ? entry : [entry]).map(el => + typeof el === 'function' + ? { actions: actionList(el) } + : { + target: el.target === undefined ? undefined : indexOf(el.target), + guard: el.guard === undefined ? undefined : guardRef(el.guard), + actions: actionList(el.actions), + }, + ) + const guarded = candidates.filter(c => c.guard !== undefined).length + if (guarded < 2) return { candidates } + return { candidates, pick: guardLists.push(candidates.map(c => c.guard ?? -1)) - 1 } + } + const onTable = (on: object | undefined): EntrySpec[] => + Object.entries((on ?? {}) as Record) + .filter(([, entry]) => entry !== undefined) + .map(([type, entry]) => ({ kind: kindOf(type), ...entrySpec(entry) })) + + const states = stateNames.map(name => { + const node = config.states[name]! + return { + on: onTable(node.on), + entry: actionList(node.entry), + exit: actionList(node.exit), + effects: (node.effects ?? []).map(e => + effectId(typeof e === 'function' ? e : (impl.effects?.[e] ?? missing('effect', e))), + ), + after: Object.entries((node.after ?? {}) as Record) + .filter(([, entry]) => entry !== undefined) + .map(([key, entry]) => { + const ms = Number(key) + const delay = Number.isNaN(ms) + ? { id: delayId(impl.delays?.[key] ?? missing('delay', key)) } + : { ms } + return { delay, ...entrySpec(entry) } + }), + } + }) + + const fieldBits = new Map() + for (const key of Object.keys(config.context)) fieldBit(fieldBits, key) + const computedNames = Object.keys(config.computed ?? {}) + const computedIndex = new Map(computedNames.map((name, i) => [name, i])) + // Watch keys are probed on computed first (fixed at build), then on context fields, + // which may appear later. + const watch = Object.entries((config.watch ?? {}) as Record) + .filter(([, list]) => list !== undefined) + .map(([key, list]) => { + const computed = computedIndex.get(key) + return computed === undefined + ? { field: fieldBit(fieldBits, key), actions: actionList(list) } + : { computed, actions: actionList(list) } + }) + + const spec = { + initial: indexOf(config.initial), + kinds: 0, + states, + onAny: onTable(config.on), + computed: computedNames.length, + watch, + } + spec.kinds = kinds.size + + const compiled: Compiled = { + rust: new JsConfig(spec), + initial: spec.initial, + stateNames, + kinds, + tags: stateNames.map(name => new Set(config.states[name]!.tags ?? [])), + fieldBits, + tracksContext: watch.length > 0 || computedNames.length > 0, + computedIndex, + computedDefs: computedNames.map(name => (config.computed as Record)[name]!), + guards, + guardLists, + actionLists, + actions, + effects, + delays, + guardRegistry: impl.guards, + } + cache.set(config, compiled) + return compiled +} diff --git a/packages/core/src/computed.ts b/packages/core/src/computed.ts deleted file mode 100644 index c23fc8c..0000000 --- a/packages/core/src/computed.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { ComputedDefs } from './types' - -// `context` / `computed` are the live objects (stable identity); only state is read through a fn. -export interface ComputedHost { - context: Context - computed: Computed - state: () => State -} - -/** - * Define computed getters on `target` with read-key tracking: each def records which - * context/computed keys it read and recomputes only when one of those inputs changed. - * Defined onto the SAME object the machine exposes as `this.computed` so computed→computed - * chains resolve in place. - */ -export function defineComputed( - target: Computed, - defs: ComputedDefs, - host: ComputedHost, -): void { - // Dep keys are runtime strings, so all dep reads are string-indexed — widen once here - // instead of casting at every read site. The proxy target is inert (traps never touch it). - const context = host.context as Record - const computed = host.computed as Record - const proxyTarget: Record = {} - - for (const key in defs) { - const k = key as keyof Computed - const def = defs[k] - let computedOnce = false - let cachedValue: Computed[keyof Computed] - let readState = false - let stateSnapshot: State | undefined - - // Parallel dep-key/dep-value buffers, reused across recomputes — a recompute - // allocates nothing. Values are captured AT read time inside the tracking - // proxies, so no post-pass re-reads (and re-validates) what was just computed. - const ctxDeps: string[] = [] - const ctxVals: unknown[] = [] - const computedDeps: string[] = [] - const computedVals: unknown[] = [] - - // True during recompute so proxy reads record deps and `params.state` records - // a state dependency. Deps are few, so the includes() dedup beats a Set. - let tracking = false - const trackedCtx = new Proxy(proxyTarget, { - get: (_t, p: string) => { - const value = context[p] - if (tracking && !ctxDeps.includes(p)) { - ctxDeps.push(p) - ctxVals.push(value) - } - return value - }, - }) as Context - - const trackedComputed = new Proxy(proxyTarget, { - get: (_t, p: string) => { - const value = computed[p] - if (tracking && !computedDeps.includes(p)) { - computedDeps.push(p) - computedVals.push(value) - } - return value - }, - }) as Computed - - // The def params never change shape — build them once, not per recompute. - const params = { - context: trackedCtx, - computed: trackedComputed, - get state() { - if (tracking) readState = true - return host.state() - }, - } - - const stale = (): boolean => { - if (readState && stateSnapshot !== host.state()) return true - - let i = 0 - while (i < ctxDeps.length) { - if (!Object.is(ctxVals[i], context[ctxDeps[i]!])) return true - i++ - } - - // Reading a computed dep resolves ITS staleness first — transitive changes surface here. - i = 0 - while (i < computedDeps.length) { - if (!Object.is(computedVals[i], computed[computedDeps[i]!])) return true - i++ - } - return false - } - - Object.defineProperty(target, k, { - enumerable: true, - get: () => { - if (computedOnce && !stale()) return cachedValue - ctxDeps.length = 0 - ctxVals.length = 0 - computedDeps.length = 0 - computedVals.length = 0 - readState = false - tracking = true - let completed = false - try { - cachedValue = def(params) as Computed[keyof Computed] - completed = true - } finally { - tracking = false - // A throwing def leaves the buffers half-filled — force the next read to recompute. - computedOnce = completed - } - stateSnapshot = readState ? host.state() : undefined - return cachedValue - }, - }) - } -} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index d559488..a853360 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -11,12 +11,3 @@ export const MACHINE_INIT = 'machine.init' as const * that resolves a name so the rule lives in one place. */ export const isDev: boolean = process.env.NODE_ENV !== 'production' - -/** - * Dev-only runaway guard for one queue flush. A single send legitimately chains - * a handful of queued events / deferred watcher runs; thousands means a - * feedback loop (e.g. a watcher whose action keeps changing the field it - * watches, or actions sending in a cycle). Far above any real chain, so a hit - * is always a bug. - */ -export const MAX_FLUSH = 10_000 diff --git a/packages/core/src/from-wasm.ts b/packages/core/src/from-wasm.ts new file mode 100644 index 0000000..f9827e1 --- /dev/null +++ b/packages/core/src/from-wasm.ts @@ -0,0 +1,146 @@ +import { HOST, MachineClass, type Engine, type Shape } from './machine' +import type { Machine } from './types' + +/** An instance of a class exported with `dunky_wasm::export_machine!`. */ +export interface WasmMachine extends Engine { + /** Connect to the host; `facade` is the facade calling. Returns a status. */ + attach: (host: object, facade: object) => number + field: (index: number) => unknown + meta: () => WasmMachineMeta +} + +/** The names a Rust machine type reports, in its numbering. */ +export interface WasmMachineMeta { + states: string[] + events: string[] + fields: string[] + computed: string[] + /** Per state: its tags. */ + tags: string[][] +} + +/** How to produce one computed value on the JS side. */ +export type ComputedMapping = + | ((raw: unknown) => Value) + | { + /** Read this Rust computed value instead (e.g. indices instead of objects). */ + from: string + map: (raw: unknown) => Value + } + +export interface FromWasmOptions { + /** + * Map a raw computed value — e.g. indices that crossed the boundary, back onto the + * host's own objects. Runs only when the value changed. + */ + computed?: { [K in keyof Computed]?: ComputedMapping } +} + +interface RustShape extends Shape { + fields: string[] + computedIndex: Map +} + +// The shape is per machine TYPE: read it once per exported class, not per instance. +const shapes = new WeakMap() + +function shapeOf(handle: WasmMachine): RustShape { + const type = Object.getPrototypeOf(handle) as object + let shape = shapes.get(type) + if (!shape) { + const meta = handle.meta() + shape = { + stateNames: meta.states, + tags: meta.tags.map(tags => new Set(tags)), + kinds: new Map(meta.events.map((name, i) => [name, i])), + fields: meta.fields, + computedIndex: new Map(meta.computed.map((name, i) => [name, i])), + } + shapes.set(type, shape) + } + return shape +} + +const UNREAD = {} + +/** A Rust machine: the context is a mirror of the Rust one, re-read field by field. */ +class RustMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +> extends MachineClass { + fields: string[] + declare handle: WasmMachine + + constructor( + shape: RustShape, + handle: WasmMachine, + mappings: Record | undefined>, + ) { + const ctx: Record = {} + for (let i = 0; i < shape.fields.length; i++) ctx[shape.fields[i]!] = handle.field(i) + super(shape, ctx as Context, handle.state()) + this.fields = shape.fields + this.handle = handle + for (const [name, own] of shape.computedIndex) { + const mapping = mappings[name] + const map = typeof mapping === 'function' ? mapping : mapping?.map + const source = typeof mapping === 'object' ? shape.computedIndex.get(mapping.from) : own + if (source === undefined) { + throw new Error(`[machine] no computed "${(mapping as { from: string }).from}"`) + } + // Rust serves the same JS value until it changes, so identity marks a change. + let raw: unknown = UNREAD + let value: unknown + Object.defineProperty(this.computed, name, { + enumerable: true, + get: map + ? () => { + const next = handle.computed(source, this) + if (next !== raw) { + raw = next + value = map(next) + } + return value + } + : () => handle.computed(source, this), + }) + } + const status = handle.attach(HOST, this) + if (status !== 0) this.raise(status) + } + + override refresh(lo: number, hi: number): void { + const ctx = this.ctx as Record + for (let bits = lo; bits !== 0; bits &= bits - 1) { + const i = 31 - Math.clz32(bits & -bits) + ctx[this.fields[i]!] = this.handle.field(i) + } + for (let bits = hi; bits !== 0; bits &= bits - 1) { + const i = 63 - Math.clz32(bits & -bits) + ctx[this.fields[i]!] = this.handle.field(i) + } + } +} + +/** + * Wrap a Rust machine (an instance of a class exported with + * `dunky_wasm::export_machine!`) in the `Machine` interface, so the connector and + * every target consume it like a TS-authored one. + */ +export function fromWasm< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + handle: WasmMachine, + options: FromWasmOptions = {}, +): Machine { + return new RustMachine( + shapeOf(handle), + handle, + (options.computed ?? {}) as Record | undefined>, + ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ad50c9..4496859 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,10 +7,13 @@ export { connector } from './connector' export { makeReaction } from './reaction' export { compose, type Composition } from './compose' export { createStore, type Store, type Listener, type SetStateAction } from './store' -// Building blocks for other Machine implementations (e.g. the wasm adapter), so every -// implementation shares one broadcast and one selection semantics. -export { makeBroadcast, type Broadcast } from './broadcast' -export { makeSelection } from './selection' +export { + fromWasm, + type ComputedMapping, + type FromWasmOptions, + type WasmMachine, + type WasmMachineMeta, +} from './from-wasm' export type { Machine, diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 1f66d6a..ba100b2 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,289 +1,161 @@ -import { type ActionHost, runActions } from './actions' -import { makeBroadcast } from './broadcast' -import { defineComputed } from './computed' -import { isDev, MACHINE_INIT, MAX_FLUSH } from './constants' +import { makeBroadcast, type Broadcast } from './broadcast' +import { compile, fieldBit, type Compiled } from './compile' +import { MACHINE_INIT } from './constants' import { makeGuardParams } from './guards' -import { shouldPatch } from './patch' import { makeSelection } from './selection' -import { lookupOn, resolve } from './transitions' +import { JsMachine } from './wasm' import type { - Actions, + ActionParams, + GuardParams, Machine, Select, Selection, - StateNode, - Transition, TransitionConfig, } from './types' -// Tags depend only on static config — derive once per states-map, share across instances. -const tagsCache = new WeakMap>>() -function tagsForStates( - states: Record, -): Record> { - let tags = tagsCache.get(states) as Record> | undefined - if (!tags) { - tags = {} as Record> - for (const name in states) tags[name as State] = new Set(states[name as State].tags ?? []) - tagsCache.set(states, tags) - } - return tags +/** + * What the facade drives: a machine on the Rust engine. `JsMachine` (a TS-authored + * machine) and every class exported with `dunky_wasm::export_machine!` (a Rust machine) + * implement it. Calls that run machine code return a status (see `OK`). + */ +export interface Engine { + // `facade` is the facade calling: the host functions of that call receive it. + send: (kind: number, event: unknown, facade: object) => number + start: (facade: object) => number + stop: (facade: object) => number + running: () => boolean + state: () => number + computed: (id: number, facade: object) => unknown + /** The host clock: timer `id` came due. */ + fire: (id: number, facade: object) => number + /** The engine's failure behind `ENGINE_FAILED`. */ + takeFailure: () => string | undefined +} + +// The statuses of an engine call (crates/wasm/src/bridge.rs). JS owns the errors: a +// host function catches what user code throws and keeps it on the machine. +const OK = 0 +const FAILED = 1 +const ENGINE_FAILED = 3 +const NONE = Symbol('none') + +/** The names the facade maps the engine's numbers onto. */ +export interface Shape { + stateNames: string[] + tags: Array> + kinds: Map } // One shared instance — the boot event carries no payload, so nothing needs a fresh object. const INIT_EVENT = Object.freeze({ type: MACHINE_INIT }) -class MachineClass< +/** + * The `Machine` interface over the Rust engine. Rust owns the graph: the queue, + * transition resolution, entry/exit order, the effects lifecycle, `after` timers, + * watchers and computed bookkeeping. The facade maps names to numbers, keeps the + * context object and fans each change out to the subscribers. + */ +export class MachineClass< State extends string, Context extends object, Event extends { type: string }, Computed, -> { - config: TransitionConfig +> implements Machine { + shape: Shape + handle!: Engine ctx: Context - stateValue: State - tagsOf: Record> - broadcast = makeBroadcast() - // Run-to-completion queue. Events (objects) and deferred jobs (functions) both wait for - // the in-flight transition to finish before running. - queue: Array void)> = [] - flushing = false - running = false - // Bumped on every state ENTRY. An `after` timer captures the generation at schedule time; - // if the machine exits and re-enters the same state before the timer fires, the generation - // no longer matches and the stale timer is ignored. - entryCounter = 0 - stateCleanups: Array<() => void> = [] - watcherCleanups: Array<() => void> = [] + computed = {} as Computed + /** The current state's index, moved by every notify. */ + current: number + broadcast: Broadcast = makeBroadcast() // Lazily created — a machine with no connector pays nothing. startListeners: Set<() => void> | null = null stopListeners: Set<() => void> | null = null - computed: Computed - actionHost: ActionHost + selectFacade: Select | null = null + /** The first error user code threw in the current call. */ + error: unknown = NONE + /** Bumped on every change (a context write, a state change): a computed value read at + * the same epoch is still valid. */ + epoch = 0 + /** Nesting of engine calls: JS code calls back in from inside one. */ + depth = 0 - constructor(config: TransitionConfig) { - this.config = config - // Own copy from birth — identity never changes, writes mutate in place. - // Refs captured in effects/actions always see the live context. - this.ctx = { ...config.context } - this.stateValue = config.initial - this.tagsOf = tagsForStates(config.states) + constructor(shape: Shape, context: Context, current: number) { + this.shape = shape + this.ctx = context + this.current = current + } - this.computed = {} as Computed - if (config.computed) { - defineComputed(this.computed, config.computed, { - context: this.ctx, - computed: this.computed, - state: () => this.stateValue, - }) - } + /** A change named context fields `lo`/`hi` (the low and high 32 bits of the mask). */ + refresh(_lo: number, _hi: number): void {} - this.actionHost = { - actions: config.implementations?.actions, - guards: config.implementations?.guards, - context: this.ctx, - computed: this.computed, - setContext: this.setContext, - send: this.send, - } + /** The machine is in `state` now: tell the subscribers. */ + enter(state: number): void { + this.current = state + this.epoch++ + this.broadcast.notify() } - setContext = (patch: Partial): void => { - if (!shouldPatch(this.ctx, patch)) return - Object.assign(this.ctx, patch) // in place — this.ctx identity never changes - this.broadcast.notify() + /** Keep the first error of the call; the status tells the engine to stop the step. */ + fail(error: unknown): number { + if (this.error === NONE) this.error = error + return FAILED + } + /** Throw what failed: the outermost call takes the error, a nested one leaves it. */ + raise(status: number): never { + if (status === ENGINE_FAILED) throw new Error(this.handle.takeFailure()) + const error = this.error + if (this.depth === 0) this.error = NONE + throw error } + send = (event: Event): void => { - this.enqueue(event) + const kind = this.shape.kinds.get(event.type) + if (kind === undefined) return // handled nowhere: a no-op + this.depth++ + const status = this.handle.send(kind, event, this) + this.depth-- + if (status !== OK) this.raise(status) + } + fire(id: number): void { + this.depth++ + const status = this.handle.fire(id, this) + this.depth-- + if (status !== OK) this.raise(status) } get state(): State { - return this.stateValue + return this.shape.stateNames[this.current] as State } get context(): Context { return this.ctx } hasTag(tag: string): boolean { - return this.tagsOf[this.stateValue].has(tag) + return this.shape.tags[this.current]!.has(tag) } matches(name: State): boolean { - return this.stateValue === name - } - - private setState(next: State): void { - if (next === this.stateValue) return - this.stateValue = next - this.broadcast.notify() - } - - private runActions(actions: Actions | undefined, event: Event): void { - runActions(this.actionHost, actions, event) - } - - private applyTransition(t: Transition, event: Event): void { - const cur = this.stateValue - const next = t.target ?? cur - const changed = next !== cur - if (changed) { - if (this.running) this.stopEffects() - this.runActions(this.config.states[cur].exit, event) - } - this.runActions(t.actions, event) - if (changed) { - this.setState(next) - this.runActions(this.config.states[next].entry, event) - if (this.running) this.startEffects(next, event) - } - } - // Re-entrant enqueues (send from an action, watcher mid-transition) wait for the current flush. - private enqueue(item: Event | (() => void)): void { - this.queue.push(item) - if (this.flushing) return - this.flushing = true - try { - this.flushQueue() - } finally { - this.flushing = false - } - } - private flushQueue(): void { - let ticks = 0 - while (this.queue.length) { - if (isDev && ++ticks > MAX_FLUSH) { - throw new Error( - `[machine] one flush exceeded ${MAX_FLUSH} steps — feedback loop ` + - '(e.g. a watcher writing the field it watches, or actions sending in a cycle)', - ) - } - const item = this.queue.shift()! - if (typeof item === 'function') { - item() - continue - } - const t = resolve(lookupOn(this.config, this.stateValue, item.type), item, this.actionHost) - if (t) this.applyTransition(t, item) - } - } - private resolveDelay(key: string, event: Event): number { - const asNum = Number(key) - if (!Number.isNaN(asNum)) return asNum - const fn = this.config.implementations?.delays?.[key] - if (!fn) { - const msg = `[machine] no delay "${key}"` - if (isDev) throw new Error(msg) - console.warn(msg) - return 0 - } - return fn(makeGuardParams(this.ctx, event, this.computed, this.config.implementations?.guards)) - } - // Runs as a queued job so the timer joins the run-to-completion queue like any send. - // Stale when the machine stopped or the state was exited (and maybe re-entered) since scheduling. - private dispatchAfter(scheduledIn: State, key: string, event: Event, generation: number): void { - if (!this.running || this.entryCounter !== generation) return - const t = resolve(this.config.states[scheduledIn].after?.[key], event, this.actionHost) - if (t) this.applyTransition(t, event) - } - - private startEffects(state: State, event: Event): void { - const generation = ++this.entryCounter - const after = this.config.states[state].after - if (after) { - for (const key in after) { - const ms = this.resolveDelay(key, event) - const id = setTimeout( - () => this.enqueue(() => this.dispatchAfter(state, key, event, generation)), - ms, - ) - this.stateCleanups.push(() => clearTimeout(id)) - } - } - const effects = this.config.states[state].effects - if (!effects) return - for (const effect of effects) { - const fn = - typeof effect === 'function' ? effect : this.config.implementations?.effects?.[effect] - if (!fn) { - const msg = `[machine] no effect "${effect as string}"` - if (isDev) throw new Error(msg) - console.warn(msg) - continue - } - const cleanup = fn({ - context: this.ctx, - setContext: this.setContext, - event, - send: this.send, - computed: this.computed, - }) - if (typeof cleanup === 'function') this.stateCleanups.push(cleanup) - } - } - private stopEffects(): void { - // A throwing cleanup must not leak the others (timers, subscriptions) or leave the - // list populated for a double run on the next stop. Finish the pass, rethrow after. - const cleanups = this.stateCleanups - let thrown: unknown - let didThrow = false - for (const cleanup of cleanups) { - try { - cleanup() - } catch (error) { - if (!didThrow) { - didThrow = true - thrown = error - } - } - } - cleanups.length = 0 - if (didThrow) throw thrown - } - - private startWatchers(): void { - const watch = this.config.watch - if (!watch) return - for (const key in watch) { - const actions = watch[key as keyof typeof watch] - if (!actions) continue - // Bind the source once: computed keys are fixed at construction, while ctx keys - // may appear later (optional fields patched in), so membership is probed on computed. - const source = (key in (this.computed as object) ? this.computed : this.ctx) as Record< - string, - unknown - > - // Defer: the selection fires inside notify() (mid-transition). Running actions immediately - // would be re-entrant. The `running` check at job time drops pending runs on stop(). - const off = this.makeSelection(() => source[key]).subscribe(() => { - this.enqueue(() => { - if (this.running) this.runActions(actions, INIT_EVENT as Event) - }) - }) - this.watcherCleanups.push(off) - } - } - private stopWatchers(): void { - for (const dispose of this.watcherCleanups) dispose() - this.watcherCleanups.length = 0 + return this.shape.stateNames[this.current] === name } start = (): void => { - if (this.running) return - this.running = true - this.startWatchers() - // Boot the CURRENT state's effects — stop() doesn't reset stateValue, so a - // restart (e.g. StrictMode mount→unmount→mount) may be in any state. - this.startEffects(this.stateValue, INIT_EVENT as Event) + if (this.handle.running()) return + this.depth++ + const status = this.handle.start(this) + this.depth-- + if (status !== OK) this.raise(status) if (this.startListeners) for (const fn of this.startListeners) fn() } stop = (): void => { - if (!this.running) return - this.running = false - this.stopEffects() - this.stopWatchers() + if (!this.handle.running()) return + this.depth++ + const status = this.handle.stop(this) + this.depth-- + if (status !== OK) this.raise(status) if (this.stopListeners) for (const fn of this.stopListeners) fn() } onStart = (fn: () => void): (() => void) => { ;(this.startListeners ??= new Set()).add(fn) - if (this.running) fn() // already running — fire immediately so late registrants don't miss it + if (this.handle.running()) fn() // already running — fire immediately so late registrants don't miss it return () => this.startListeners?.delete(fn) } onStop = (fn: () => void): (() => void) => { @@ -297,7 +169,6 @@ class MachineClass< return makeSelection(selector, onWake => this.broadcast.add(onWake)) } // Built on first access, then reused — the facade is stateless, so one instance serves all reads. - selectFacade: Select | null = null get select(): Select { if (this.selectFacade) return this.selectFacade const sel = ((selector: () => Value) => this.makeSelection(selector)) as Select< @@ -308,14 +179,339 @@ class MachineClass< sel.context = (key: K) => this.makeSelection(() => this.ctx[key]) sel.computed = (key: K) => this.makeSelection(() => this.computed[key]) - sel.state = () => this.makeSelection(() => this.stateValue) + sel.state = () => this.makeSelection(() => this.state) return (this.selectFacade = sel) } } +// What the computed evaluation in progress read. Evaluations nest (a computed value +// reading another), so each one saves and restores the frame around it. +let readLo = 0 +let readHi = 0 +let readsState = false +let reads: number[] | null = null + +function markRead(bit: number): void { + if (bit < 32) readLo |= 1 << bit + else readHi |= 1 << (bit - 32) +} + +// The params of the dispatch in progress: its guards and actions share them. One slot +// for all machines, so no machine keeps a dispatch's params alive. +let paramsOf: object | null = null +let paramsEvent: unknown = null +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let actionParams: ActionParams | null = null +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let guardParams: GuardParams | null = null + +function reuse(machine: object, event: unknown): void { + paramsOf = machine + paramsEvent = event + actionParams = null + guardParams = null +} + +/** + * A TS-authored machine. Rust calls back (the host functions below) to run its user + * code; the context is a plain JS object, mutated in place, and JS reports each write's + * change mask so Rust can drive the watchers and computed staleness. + */ +class TsMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +> extends MachineClass { + c: Compiled + declare handle: JsMachine + cleanups: Map void> | null = null + nextCleanup = 1 + /** Per computed value: the epoch it was last read at, and the value. */ + readAt: number[] + readValue: unknown[] + tracked: { context: Context; computed: Computed; readonly state: State } | null = null + + constructor(c: Compiled, context: Context) { + // Own copy from birth — identity never changes, writes mutate in place. + // Refs captured in effects/actions always see the live context. + super(c, { ...context }, c.initial) + this.c = c + this.readAt = c.computedDefs.map(() => -1) + this.readValue = c.computedDefs.map(() => undefined) + for (const [name, id] of c.computedIndex) { + Object.defineProperty(this.computed, name, { enumerable: true, get: () => this.read(id) }) + } + // Rust holds the facade only during a call: only JS references (a consumer, an + // effect's `send`, a pending timer) keep the machine alive, as with any JS object. + this.handle = new JsMachine(c.rust, HOST) + } + + setContext = (patch: Partial): void => { + const ctx = this.ctx as Record + // Only watchers and computed values need the engine to hear about a write. + if (!this.c.tracksContext) { + let changed = false + for (const key in patch) { + const value = (patch as Record)[key] + if (Object.is(ctx[key], value)) continue + ctx[key] = value // in place — ctx identity never changes + changed = true + } + if (changed) { + this.epoch++ + this.broadcast.notify() + } + return + } + let lo = 0 + let hi = 0 + for (const key in patch) { + const value = (patch as Record)[key] + if (Object.is(ctx[key], value)) continue + ctx[key] = value + const bit = fieldBit(this.c.fieldBits, key) + if (bit < 32) lo |= 1 << bit + else hi |= 1 << (bit - 32) + } + if ((lo | hi) === 0) return + this.epoch++ + this.depth++ + const status = this.handle.markChanged(lo >>> 0, hi >>> 0, this) + this.depth-- + if (status !== OK) this.raise(status) + this.broadcast.notify() + } + + /** Read computed `id`; throw if its definition failed during this read. */ + read(id: number): unknown { + // Every input of a TS machine changes through JS, so an unchanged epoch means an + // unchanged value: no call into the engine. + if (this.readAt[id] === this.epoch) return this.readValue[id] + const before = this.error + this.depth++ + const value = this.handle.computed(id, this) + this.depth-- + if (this.error !== before) this.raise(FAILED) + this.readAt[id] = this.epoch + this.readValue[id] = value + return value + } + + actionParams(event: unknown): ActionParams { + const e = event ?? INIT_EVENT + if (paramsOf !== this || paramsEvent !== e) reuse(this, e) + return (actionParams ??= { + context: this.ctx, + setContext: this.setContext, + event: e, + send: this.send, + computed: this.computed, + }) as ActionParams + } + guardParams(event: unknown): GuardParams { + const e = event ?? INIT_EVENT + if (paramsOf !== this || paramsEvent !== e) reuse(this, e) + return (guardParams ??= makeGuardParams( + this.ctx, + e, + this.computed, + this.c.guardRegistry, + )) as GuardParams + } + + /** Run computed `id`; report its value and what it read to Rust, which owns its staleness. */ + evaluate(id: number): number { + const lo = readLo + const hi = readHi + const state = readsState + const outer = reads + readLo = 0 + readHi = 0 + readsState = false + reads = null + try { + const value = this.c.computedDefs[id]!(this.trackedParams()) + this.handle.report( + value, + readLo >>> 0, + readHi >>> 0, + readsState, + reads ? Uint32Array.from(reads) : undefined, + ) + return OK + } finally { + readLo = lo + readHi = hi + readsState = state + reads = outer + } + } + trackedParams(): { context: Context; computed: Computed; readonly state: State } { + if (this.tracked) return this.tracked + const { c, handle } = this + const machine = this + const context = new Proxy(this.ctx, { + get(target, key) { + if (typeof key === 'string') markRead(fieldBit(c.fieldBits, key)) + return Reflect.get(target, key) + }, + }) + const computed = new Proxy(this.computed as object, { + get(target, key) { + const id = typeof key === 'string' ? c.computedIndex.get(key) : undefined + if (id === undefined) return Reflect.get(target, key) + reads ??= [] + if (!reads.includes(id)) reads.push(id) + return machine.read(id) + }, + }) as Computed + return (this.tracked = { + context, + computed, + get state() { + readsState = true + // Read live: a computed value can run mid-transition, before the notify that + // moves `current`. + return c.stateNames[handle.state()] as State + }, + }) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Facade = MachineClass +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TsFacade = TsMachine +type Timeout = ReturnType + +/** The JS functions the engine calls back into (see crates/wasm/src/bridge.rs). They + * never throw: each catches what user code throws, keeps it on the machine, and returns + * a failure status. */ +interface Host { + /** 0 false, 1 true, 2 failed. */ + guard: (m: TsFacade, fn: number, event: unknown) => number + /** `notify`: a state change to announce first (it rides on this call), or -1. */ + action: (m: TsFacade, fn: number, event: unknown, notify: number) => number + /** The cleanup's id, 0 for no cleanup, -1 failed. */ + effect: (m: TsFacade, fn: number, event: unknown, notify: number) => number + cleanup: (m: TsFacade, id: number) => number + /** The delay in ms, -1 failed. */ + delay: (m: TsFacade, fn: number, event: unknown) => number + computed: (m: TsFacade, id: number) => number + /** The first candidate of guard list `list` that passes, -1 for none, -2 failed. */ + pick: (m: TsFacade, list: number, event: unknown) => number + actions: (m: TsFacade, list: number, event: unknown, notify: number) => number + notify: (m: Facade, state: number, lo: number, hi: number) => number + startTimer: (m: Facade, id: number, ms: number) => Timeout + cancelTimer: (timeout: Timeout) => void +} + +/** + * The host functions Rust calls, with the facade of the call in progress and, for user + * code, the callback's index in the compiled tables. + */ +export const HOST: Host = { + guard: (m, fn, event) => { + try { + return m.c.guards[fn]!(m.guardParams(event)) ? 1 : 0 + } catch (error) { + m.fail(error) + return 2 + } + }, + action: (m, fn, event, notify) => { + try { + if (notify >= 0) m.enter(notify) + m.c.actions[fn]!(m.actionParams(event)) + return OK + } catch (error) { + return m.fail(error) + } + }, + effect: (m, fn, event, notify) => { + try { + if (notify >= 0) m.enter(notify) + const cleanup = m.c.effects[fn]!(m.actionParams(event)) + if (typeof cleanup !== 'function') return 0 + ;(m.cleanups ??= new Map()).set(m.nextCleanup, cleanup) + return m.nextCleanup++ + } catch (error) { + m.fail(error) + return -1 + } + }, + cleanup: (m, id) => { + const cleanup = m.cleanups!.get(id)! + m.cleanups!.delete(id) + try { + cleanup() + return OK + } catch (error) { + return m.fail(error) + } + }, + delay: (m, fn, event) => { + try { + const ms = m.c.delays[fn]!(m.guardParams(event)) + return ms > 0 ? ms : 0 // like setTimeout: negative or NaN means now + } catch (error) { + m.fail(error) + return -1 + } + }, + computed: (m, id) => { + try { + return m.evaluate(id) + } catch (error) { + return m.fail(error) + } + }, + pick: (m, list, event) => { + try { + const candidates = m.c.guardLists[list]! + const params = m.guardParams(event) + for (let i = 0; i < candidates.length; i++) { + const guard = candidates[i]! + if (guard < 0 || m.c.guards[guard]!(params)) return i + } + return -1 + } catch (error) { + m.fail(error) + return -2 + } + }, + actions: (m, list, event, notify) => { + try { + if (notify >= 0) m.enter(notify) + const actions = m.c.actionLists[list]! + const params = m.actionParams(event) + for (let i = 0; i < actions.length; i++) m.c.actions[actions[i]!]!(params) + return OK + } catch (error) { + return m.fail(error) + } + }, + notify: (m, state, lo, hi) => { + try { + if (lo | hi) { + m.current = state + m.refresh(lo, hi) + m.broadcast.notify() + } else m.enter(state) + return OK + } catch (error) { + return m.fail(error) + } + }, + // The pending timer holds the machine, like a JS timer holds `this`. + startTimer: (m, id, ms) => setTimeout(() => m.fire(id), ms), + cancelTimer: timeout => clearTimeout(timeout), +} + /** * What a bridge can build a service from: a config (run by this engine) or a ready - * machine — e.g. a Rust machine behind `@dunky.dev/state-machine-wasm`. + * machine — e.g. a Rust machine wrapped with `fromWasm`. */ export type MachineSource< State extends string, @@ -346,5 +542,5 @@ export function machine< >( config: TransitionConfig, ): Machine { - return new MachineClass(config) as unknown as Machine + return new TsMachine(compile(config), config.context) } diff --git a/packages/core/src/transitions.ts b/packages/core/src/transitions.ts deleted file mode 100644 index 7036724..0000000 --- a/packages/core/src/transitions.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { makeGuardParams } from './guards' -import type { Guard, GuardParams, Transition, TransitionConfig, TransitionEntry } from './types' - -/** What a guard needs to run — the machine's action host satisfies it. */ -export interface GuardHost { - context: Context - computed: Computed - guards: Record> | undefined -} - -/** Look up the `on` entry for an event: current state first, falling back to `config.on`. */ -export function lookupOn< - State extends string, - Context extends object, - Event extends { type: string }, - Computed, ->( - config: TransitionConfig, - stateValue: State, - type: Event['type'], -): TransitionEntry | undefined { - const onState = config.states[stateValue].on as - | Record> - | undefined - const onAny = config.on as - | Record> - | undefined - return onState?.[type] ?? onAny?.[type] -} - -/** - * Return the first transition whose guard passes, across the three entry forms - * (object / bare fn / array). A bare fn is a guardless `{ actions: fn }`. - * Guard params are built once per resolve and only when a guard is met — the - * common guardless send allocates nothing here. - */ -export function resolve( - entry: TransitionEntry | undefined, - event: Event, - host: GuardHost, -): Transition | undefined { - if (entry === undefined) return undefined - if (!Array.isArray(entry)) { - if (typeof entry === 'function') return { actions: entry } - if (!entry.guard) return entry - const params = makeGuardParams(host.context, event, host.computed, host.guards) - return params.guard(entry.guard) ? entry : undefined - } - let params: GuardParams | undefined - for (const el of entry) { - if (typeof el === 'function') return { actions: el } - if (!el.guard) return el - params ??= makeGuardParams(host.context, event, host.computed, host.guards) - if (params.guard(el.guard)) return el - } - return undefined -} diff --git a/packages/core/src/wasm.ts b/packages/core/src/wasm.ts new file mode 100644 index 0000000..c4d24a2 --- /dev/null +++ b/packages/core/src/wasm.ts @@ -0,0 +1,21 @@ +/** + * The engine: Rust (crates/core-wasm) compiled to wasm, built by `pnpm build:wasm`. The + * bytes are inlined, so it starts synchronously — no `await init()` — on first use. + */ +import { engineHeapBytes, initSync, JsConfig, JsMachine } from '../wasm/dunky_core_wasm.js' +import { WASM } from '../wasm/bytes.js' + +let started = false + +/** Instantiate the engine once. Lazy, so importing the package costs nothing. */ +export function startEngine(): void { + if (started) return + const binary = atob(WASM) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + initSync({ module: bytes }) + started = true +} + +// `engineHeapBytes`: the engine's live heap, for the benchmark (not public API). +export { engineHeapBytes, JsConfig, JsMachine } diff --git a/packages/core/tests/from-wasm.test.ts b/packages/core/tests/from-wasm.test.ts new file mode 100644 index 0000000..7f170e2 --- /dev/null +++ b/packages/core/tests/from-wasm.test.ts @@ -0,0 +1,107 @@ +/** + * `fromWasm` against a scripted handle that plays the Rust side of the protocol: + * events in by kind, a notify per change naming the changed fields, computed values + * served by identity. The facade behavior both machine kinds share (subscribe, select, + * lifecycle, timers) is pinned by the TS-machine tests. + */ +import { describe, expect, it, vi } from 'vitest' +import { fromWasm, type WasmMachine, type WasmMachineMeta } from '../src' +import type { HOST } from '../src/machine' + +const FIELDS = Array.from({ length: 40 }, (_, i) => `f${i}`) + +function fake() { + let host: typeof HOST + let me: object + const rust = { + state: 0, + values: FIELDS.map(() => 0), + computed: [[2, 0], 'other'] as unknown[], + } + const reads = { field: 0 } + const handle = { + attach: (h: object, ref: object) => { + host = h as typeof HOST + me = ref + return 0 + }, + send: vi.fn((_kind: number, _event: unknown, _facade: object) => 0), + start: () => 0, + stop: () => 0, + running: () => false, + state: () => rust.state, + computed: (id: number) => rust.computed[id], + fire: vi.fn(() => 0), + takeFailure: () => undefined, + field: (i: number) => { + reads.field++ + return rust.values[i] + }, + meta: (): WasmMachineMeta => ({ + states: ['idle', 'busy'], + events: ['go', 'set'], + fields: FIELDS, + computed: ['indices', 'other'], + tags: [[], ['working']], + }), + } satisfies WasmMachine + /** Rust changed: the state, and the fields at `changed`. */ + const change = (state: number, changed: Record) => { + rust.state = state + let lo = 0 + let hi = 0 + for (const [i, value] of Object.entries(changed)) { + rust.values[Number(i)] = value + if (Number(i) < 32) lo |= 1 << Number(i) + else hi |= 1 << (Number(i) - 32) + } + host.notify(me as never, state, lo >>> 0, hi >>> 0) + } + return { handle, rust, reads, change } +} + +type Ctx = Record + +describe('fromWasm', () => { + it('sends events by kind, with the event object for the payload; ignores unknown types', () => { + const { handle } = fake() + const m = fromWasm<'idle' | 'busy', Ctx, { type: string; v?: number }>(handle) + m.send({ type: 'go' }) + m.send({ type: 'set', v: 1 }) + m.send({ type: 'nope' }) + expect(handle.send.mock.calls.map(([kind, event]) => [kind, event])).toEqual([ + [0, { type: 'go' }], + [1, { type: 'set', v: 1 }], + ]) + }) + + it('moves the state on each notify and re-reads only the fields it names', () => { + const { handle, reads, change } = fake() + const m = fromWasm<'idle' | 'busy', Ctx, { type: 'go' }>(handle) + const context = m.context + const listener = vi.fn(() => [m.state, m.context.f1, m.context.f35]) + m.subscribe(listener) + reads.field = 0 + change(1, { 1: 7, 35: 9 }) + expect(listener).toHaveReturnedWith(['busy', 7, 9]) + expect(m.hasTag('working')).toBe(true) + expect(reads.field).toBe(2) + expect(m.context).toBe(context) + }) + + it('maps a computed value only when Rust serves a new one; `from` reads another value', () => { + const { handle, rust } = fake() + const own = ['x', 'y', 'z'] + const map = vi.fn((raw: unknown) => (raw as number[]).map(i => own[i]!)) + const m = fromWasm<'idle', Ctx, { type: 'go' }, { indices: string[]; other: string[] }>( + handle, + { computed: { indices: map, other: { from: 'indices', map } } }, + ) + expect(m.computed.indices).toEqual(['z', 'x']) + expect(m.computed.indices).toBe(m.computed.indices) + expect(map).toHaveBeenCalledTimes(1) + rust.computed[0] = [1] + expect(m.computed.indices).toEqual(['y']) + expect(m.computed.other).toEqual(['y']) + }) +}) diff --git a/packages/demo-wasm/package.json b/packages/demo-wasm/package.json deleted file mode 100644 index b2cf5e0..0000000 --- a/packages/demo-wasm/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@dunky.dev/demo-wasm", - "version": "0.0.0", - "private": true, - "description": "The demo machines compiled from Rust (crates/demo) to wasm, wrapped as TS Machines. Build the wasm first: pnpm build:wasm.", - "type": "module", - "sideEffects": false, - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./node": "./src/node.ts", - "./pkg/*": "./pkg/*" - }, - "dependencies": { - "@dunky.dev/state-machine": "workspace:*", - "@dunky.dev/state-machine-wasm": "workspace:*", - "@sandbox/cmdk-core": "workspace:*" - } -} diff --git a/packages/demo-wasm/src/index.ts b/packages/demo-wasm/src/index.ts deleted file mode 100644 index bac6dfe..0000000 --- a/packages/demo-wasm/src/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * The Rust demo machines (crates/demo) behind the TS `Machine` interface. - * - * Load the wasm once — `await loadDemo()` in a browser, `loadDemoSync(bytes)` (or - * `./node`) in Node — then build machines with the factories below. - */ -import type { Machine } from '@dunky.dev/state-machine' -import { fromWasm, type WasmMachineHandle } from '@dunky.dev/state-machine-wasm' -import type { - Command, - CommandPaletteComputed, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteState, -} from '@sandbox/cmdk-core' -import init, { - CellMachine, - DialogMachine, - GuardsMachine, - HighlightMachine, - initSync, - ListMachine, - noop, - PaletteMachine, - PingPongMachine, - sink, -} from '../pkg/dunky_demo_wasm.js' - -export { noop, sink } - -/** Load the wasm in a browser (fetches `dunky_demo_wasm_bg.wasm` next to the glue). */ -export async function loadDemo(input?: Parameters[0]): Promise { - await init(input) -} - -/** Load the wasm synchronously from its bytes (Node, tests, workers). */ -export function loadDemoSync(bytes: BufferSource): void { - initSync({ module: bytes }) -} - -export type PaletteMachineTS = Machine< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed -> - -/** The command palette. `results` crosses the boundary as indices and maps back onto - * the caller's own command objects (Rust owns ids, rich values stay in JS). */ -export function createPalette(commands: Command[]): PaletteMachineTS { - const handle = new PaletteMachine(commands) as unknown as WasmMachineHandle - return fromWasm< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed - >(handle, { - computed: { - results: { - from: 'resultIndices', - map: raw => Array.from(raw as ArrayLike, i => commands[i]!), - }, - }, - }) -} - -/** The same palette, but `results` crosses the boundary as full objects (for comparison). */ -export function createPaletteObjects(commands: Command[]): PaletteMachineTS { - return fromWasm(new PaletteMachine(commands) as unknown as WasmMachineHandle) -} - -export type CellContext = { value: number; other: number } -export function createCell(): Machine<'idle', CellContext, { type: 'hit' | 'miss' }> { - return fromWasm(new CellMachine() as unknown as WasmMachineHandle) -} - -export function createPingPong(): Machine<'ping' | 'pong', object, { type: 'go' }> { - return fromWasm(new PingPongMachine() as unknown as WasmMachineHandle) -} - -export function createGuards(k: number): Machine<'idle', { pick: number }, { type: 'go' }> { - return fromWasm(new GuardsMachine(k) as unknown as WasmMachineHandle) -} - -export type DialogState = 'closed' | 'open' | 'closing' -export type DialogContext = { exitMs: number; openCount: number } -export type DialogEvent = { type: 'open' | 'close' | 'toggle' } -export function createDialog(exitMs: number): Machine { - return fromWasm(new DialogMachine(exitMs) as unknown as WasmMachineHandle) -} - -/** The rendering bench's shared list: one highlighted index. */ -export function createList(): Machine< - 'idle', - { highlighted: number }, - { type: 'move'; to: number } -> { - return fromWasm(new ListMachine() as unknown as WasmMachineHandle) -} - -/** The rendering bench's per-row machine: one boolean. */ -export function createHighlight( - on: boolean, -): Machine<'idle', { on: boolean }, { type: 'set'; on: boolean }> { - return fromWasm(new HighlightMachine(on) as unknown as WasmMachineHandle) -} - -/** Raw handles, for measuring the boundary without the adapter. */ -export const raw = { PaletteMachine, CellMachine, PingPongMachine, GuardsMachine, DialogMachine } diff --git a/packages/demo-wasm/src/node.ts b/packages/demo-wasm/src/node.ts deleted file mode 100644 index 1081d75..0000000 --- a/packages/demo-wasm/src/node.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readFileSync } from 'node:fs' -import { loadDemoSync } from './index' - -/** Load the demo wasm in Node from the built package. */ -export function loadDemoNode(): void { - loadDemoSync(readFileSync(new URL('../pkg/dunky_demo_wasm_bg.wasm', import.meta.url))) -} - -export * from './index' diff --git a/packages/demo-wasm/tests/parity.test.ts b/packages/demo-wasm/tests/parity.test.ts deleted file mode 100644 index cf1f3d4..0000000 --- a/packages/demo-wasm/tests/parity.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Parity: the Rust palette (through wasm + the adapter) must behave exactly like the TS - * palette in sandbox/shared for the same event sequence — state, context and computed. - * Needs the wasm build: `pnpm build:wasm`. - */ -import { beforeAll, describe, expect, it, vi } from 'vitest' -import { machine } from '@dunky.dev/state-machine' -import { - commandPaletteMachineConfig, - DEMO_COMMANDS, - type CommandPaletteEvent, -} from '@sandbox/cmdk-core' -import { createDialog, createPalette, createPaletteObjects, loadDemoNode } from '../src/node' - -beforeAll(() => loadDemoNode()) - -const script: CommandPaletteEvent[] = [ - { type: 'open' }, - { type: 'move', to: 'down' }, - { type: 'move', to: 'down' }, - { type: 'query.set', query: 'go' }, - { type: 'move', to: 'up' }, - { type: 'highlight', index: 99 }, - { type: 'query.set', query: 'zzz' }, - { type: 'move', to: 'down' }, - { type: 'execute' }, - { type: 'query.set', query: 'theme' }, - { type: 'move', to: 'last' }, - { type: 'execute' }, - { type: 'open' }, - { type: 'move', to: 'first' }, - { type: 'execute' }, - { type: 'close' }, -] - -const snapshot = (m: ReturnType) => ({ - state: m.state, - query: m.context.query, - activeIndex: m.context.activeIndex, - lastExecuted: m.context.lastExecuted, - results: m.computed.results.map(c => c.id), - activeId: m.computed.activeId, -}) - -describe('wasm palette', () => { - it('matches the TS palette step by step', () => { - const ts = machine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) - const rs = createPalette(DEMO_COMMANDS) - ts.start() - rs.start() - for (const event of script) { - ts.send(event) - rs.send(event) - expect(snapshot(rs)).toEqual(snapshot(ts)) - } - }) - - it('maps result indices back onto the caller’s own command objects', () => { - const rs = createPalette(DEMO_COMMANDS) - rs.start() - rs.send({ type: 'open' }) - expect(rs.computed.results[0]).toBe(DEMO_COMMANDS[0]) - expect(createPaletteObjects(DEMO_COMMANDS).computed.results).toEqual(DEMO_COMMANDS) - }) - - it('keeps the context mirror identity and notifies once per send', () => { - const rs = createPalette(DEMO_COMMANDS) - const ctx = rs.context - const fn = vi.fn() - rs.subscribe(fn) - rs.send({ type: 'open' }) - rs.send({ type: 'query.set', query: 'issue' }) - expect(rs.context).toBe(ctx) - expect(fn).toHaveBeenCalledTimes(2) - }) - - it('a selection fires only when its value changes', () => { - const rs = createPalette(DEMO_COMMANDS) - const seen: Array = [] - rs.select.computed('activeId').subscribe(id => seen.push(id)) - rs.send({ type: 'open' }) - rs.send({ type: 'move', to: 'down' }) - rs.send({ type: 'move', to: 'down' }) - rs.send({ type: 'query.set', query: '' }) // query unchanged, index reset - expect(seen).toEqual(['issues', 'prs', 'home']) - }) -}) - -describe('wasm dialog', () => { - it('runs the `after` timer through the host clock', () => { - vi.useFakeTimers() - try { - const d = createDialog(150) - d.start() - d.send({ type: 'open' }) - expect(d.state).toBe('open') - expect(d.hasTag('visible')).toBe(true) - d.send({ type: 'close' }) - expect(d.state).toBe('closing') - vi.advanceTimersByTime(149) - expect(d.state).toBe('closing') - vi.advanceTimersByTime(1) - expect(d.state).toBe('closed') - expect(d.context.openCount).toBe(1) - } finally { - vi.useRealTimers() - } - }) - - it('cancels the pending timer when the state is left early', () => { - vi.useFakeTimers() - try { - const d = createDialog(150) - d.start() - d.send({ type: 'open' }) - d.send({ type: 'close' }) - d.send({ type: 'open' }) // back to open before the exit delay ends - vi.advanceTimersByTime(500) - expect(d.state).toBe('open') - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) - -describe('wasm constructor', () => { - it('throws on malformed props instead of guessing', () => { - expect(() => createPalette([{ id: 1 } as unknown as (typeof DEMO_COMMANDS)[number]])).toThrow() - }) -}) diff --git a/packages/demo-wasm/tsconfig.json b/packages/demo-wasm/tsconfig.json deleted file mode 100644 index 3fd6989..0000000 --- a/packages/demo-wasm/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "tests"], - "exclude": ["**/node_modules", "**/dist"] -} diff --git a/packages/native-rust/.gitignore b/packages/native-rust/.gitignore deleted file mode 100644 index 1f22e71..0000000 --- a/packages/native-rust/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Generated by `pnpm ubrn:ios` / `pnpm ubrn:android` (see README.md) -/android/ -/build/ -/cpp/ -/ios/ -/src/generated/ -/NativeRust.podspec diff --git a/packages/native-rust/README.md b/packages/native-rust/README.md deleted file mode 100644 index 96ae99d..0000000 --- a/packages/native-rust/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# @dunky.dev/native-rust - -Private. The Rust engine for React Native. - -Hermes has no WebAssembly, so React Native cannot load the wasm build. Instead -it links the Rust crate [`crates/uniffi`](../../crates/uniffi) (`dunky-uniffi`) -as native code and calls it over JSI. The bindings and the turbo-module glue are -generated by -[uniffi-bindgen-react-native](https://jhugman.github.io/uniffi-bindgen-react-native/) -(ubrn). The Rust side speaks the handle protocol of `crates/wasm`, with values -as JSON strings, so the wasm adapter (`@dunky.dev/state-machine-wasm`) drives it -unchanged. - -``` -crates/uniffi (PaletteMachine: crates/demo's palette, uniffi proc-macros) - | - | pnpm ubrn:ios / pnpm ubrn:android - v -build/NativeRustFramework.xcframework static lib, iOS device + simulator -android/src/main/jniLibs// static lib, 4 Android ABIs -cpp/ ios/ android/ NativeRust.podspec JSI + turbo-module glue -src/generated/ TypeScript bindings + module entry - | - | src/index.ts: NativePaletteHandle -> fromWasm - v -a Machine for useMachine (sandbox/native, "Engine: Rust") -``` - -Only `src/index.ts`, `package.json`, `ubrn.config.yaml`, `tsconfig.json` and -this README are hand-written; everything else is generated and gitignored. - -```ts -import { createNativePalette } from '@dunky.dev/native-rust' - -// Same events, context and computed values as the TS palette config. -const source: CommandPaletteSource = props => createNativePalette(props.commands) -``` - -`loadNativeRust()` throws, with the reason, when the app does not carry the -module (Expo Go, or no ubrn build yet); `createNativePalette` calls it. A JS -reload does not pick up Rust changes: rebuild the libraries, then the app. - -## How to rebuild - -Once per machine: - -```sh -rustup target add aarch64-apple-ios aarch64-apple-ios-sim \ - aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android -cargo install cargo-ndk -``` - -iOS needs Xcode and CocoaPods. Android needs `ANDROID_HOME` with NDK -27.1.12297006, and JDK 17. - -After a change in `crates/uniffi` (or the crates it uses): - -```sh -cargo test --manifest-path crates/uniffi/Cargo.toml -pnpm -C packages/native-rust ubrn:ios # xcframework + bindings -pnpm -C packages/native-rust ubrn:android # jniLibs + bindings -``` - -These are debug builds; append `--release` for optimized Rust. The first run -also compiles the ubrn CLI. `pnpm -C packages/native-rust ubrn:clean` deletes -the generated files. - -Then rebuild the app. `pnpm -C sandbox/native ios` (`expo run:ios`) does all of -it; the explicit, non-interactive steps are: - -```sh -cd sandbox/native -pnpm exec expo prebuild --platform ios --no-install -(cd ios && pod install) -xcodebuild -workspace ios/cmdk.xcworkspace -scheme cmdk -configuration Debug \ - -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ - -derivedDataPath ios/build/DerivedData build -pnpm exec expo start # Metro, for the debug build - -pnpm exec expo prebuild --platform android --no-install -(cd android && ./gradlew assembleDebug) -``` diff --git a/packages/native-rust/package.json b/packages/native-rust/package.json deleted file mode 100644 index acec0a9..0000000 --- a/packages/native-rust/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@dunky.dev/native-rust", - "version": "0.0.0", - "private": true, - "description": "The Rust engine (crates/uniffi) for React Native, over JSI via uniffi-bindgen-react-native", - "homepage": "https://github.com/dunky-dev/state-machine/tree/main/packages/native-rust", - "license": "MIT", - "author": "Ivan Banov", - "repository": { - "type": "git", - "url": "git+https://github.com/dunky-dev/state-machine.git", - "directory": "packages/native-rust" - }, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./package.json": "./package.json" - }, - "scripts": { - "ubrn:ios": "ubrn build ios --config ubrn.config.yaml --and-generate", - "ubrn:android": "ubrn build android --config ubrn.config.yaml --and-generate", - "ubrn:clean": "rm -rf android build cpp ios src/generated NativeRust.podspec" - }, - "dependencies": { - "@dunky.dev/state-machine-wasm": "workspace:*", - "@sandbox/cmdk-core": "workspace:*", - "@ubjs/core": "0.31.0-6", - "uniffi-bindgen-react-native": "0.31.0-6" - }, - "peerDependencies": { - "react-native": "*" - }, - "codegenConfig": { - "name": "NativeRustSpec", - "type": "modules", - "jsSrcsDir": "src", - "android": { - "javaPackageName": "dev.dunky.nativerust" - } - } -} diff --git a/packages/native-rust/src/index.ts b/packages/native-rust/src/index.ts deleted file mode 100644 index 0f90877..0000000 --- a/packages/native-rust/src/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * The Rust command palette (crates/demo, over JSI) behind the TypeScript `Machine` - * interface. - * - * ubrn generates `PaletteMachine` into ./generated: the wasm handle protocol, with - * values as JSON strings. `NativePaletteHandle` turns it back into a - * `WasmMachineHandle`, so `fromWasm` drives it unchanged. - */ -import { - fromWasm, - type WasmMachineHandle, - type WasmMachineMeta, -} from '@dunky.dev/state-machine-wasm' -import type { - Command, - CommandPaletteComputed, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteState, -} from '@sandbox/cmdk-core' -import type { PaletteMachine } from './generated' - -type Bindings = typeof import('./generated') - -let bindings: Bindings | undefined - -/** - * Loads the Rust module into the JS runtime, once. Throws, with the reason, when this - * app does not carry it (Expo Go, or no `pnpm ubrn:ios` / `pnpm ubrn:android` yet). - */ -export function loadNativeRust(): Bindings { - if (!bindings) { - // A require in try/catch, not an import: Metro then bundles a checkout where - // ./generated does not exist yet, and the error surfaces here instead. - try { - bindings = require('./generated') as Bindings - } catch (cause) { - throw new Error( - '[native-rust] cannot load the Rust module: build it (packages/native-rust: pnpm ubrn:ios / ubrn:android), then rebuild the app', - { cause }, - ) - } - } - return bindings -} - -/** The generated `PaletteMachine` as a `WasmMachineHandle`. A class on purpose: - * `fromWasm` caches the meta per handle prototype. */ -export class NativePaletteHandle implements WasmMachineHandle { - private readonly machine: PaletteMachine - - constructor(commands: readonly Command[]) { - this.machine = new (loadNativeRust().PaletteMachine)(JSON.stringify(commands)) - } - - sendKind(kind: number): number { - return this.machine.sendKind(kind) - } - - sendPayload(kind: number, event: unknown): number { - return this.machine.sendPayloadJson(kind, JSON.stringify(event)) - } - - sendEvent(event: unknown): number { - const { type } = event as { type: string } - const kind = this.meta().events.indexOf(type) - if (kind < 0) throw new Error(`[machine] no event type "${type}"`) - return this.sendPayload(kind, event) - } - - stateIndex(): number { - return this.machine.stateIndex() - } - - field(index: number): unknown { - return JSON.parse(this.machine.fieldJson(index)) - } - - fields(): unknown[] { - return JSON.parse(this.machine.fieldsJson()) as unknown[] - } - - computed(index: number): unknown { - return JSON.parse(this.machine.computedJson(index)) - } - - computedVersion(index: number): number { - return this.machine.computedVersion(index) - } - - start(): number { - return this.machine.start() - } - - stop(): number { - return this.machine.stop() - } - - fireTimer(id: number): number { - return this.machine.fireTimer(id) - } - - takeCommands(): Uint32Array { - return Uint32Array.from(this.machine.takeCommands()) - } - - meta(): WasmMachineMeta { - return JSON.parse(this.machine.metaJson()) as WasmMachineMeta - } -} - -/** The command palette on the Rust engine. As in `createPalette` (@dunky.dev/demo-wasm), - * `results` crosses as indices and maps back onto the caller's own command objects. */ -export function createNativePalette(commands: Command[]) { - return fromWasm< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed - >(new NativePaletteHandle(commands), { - computed: { - results: { - from: 'resultIndices', - map: raw => (raw as number[]).map(i => commands[i]!), - }, - }, - }) -} diff --git a/packages/native-rust/tsconfig.json b/packages/native-rust/tsconfig.json deleted file mode 100644 index 53ce585..0000000 --- a/packages/native-rust/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src"] -} diff --git a/packages/native-rust/ubrn.config.yaml b/packages/native-rust/ubrn.config.yaml deleted file mode 100644 index 5f8e203..0000000 --- a/packages/native-rust/ubrn.config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# uniffi-bindgen-react-native: builds crates/uniffi for iOS/Android and -# generates the JSI bindings + turbo-module glue into this package. -# Reference: https://jhugman.github.io/uniffi-bindgen-react-native/reference/config-yaml.html -# -# Default targets: iOS device + arm64 simulator; all four Android ABIs, which -# the generated CMake build expects to match the app's reactNativeArchitectures. -rust: - directory: ../../crates/uniffi - manifestPath: Cargo.toml -ios: - frameworkName: build/NativeRustFramework -# All generated TypeScript goes under src/generated (gitignored); src/index.ts is -# the hand-written entry that loads it. -bindings: - ts: src/generated/bindings -turboModule: - entrypoint: src/generated/index.tsx - ts: src/generated diff --git a/packages/wasm/LICENSE b/packages/wasm/LICENSE deleted file mode 100644 index 08a9692..0000000 --- a/packages/wasm/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Ivan Banov - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/wasm/README.md b/packages/wasm/README.md deleted file mode 100644 index 882d099..0000000 --- a/packages/wasm/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# @dunky.dev/state-machine-wasm - -Run a Rust machine (written with [`dunky-core`](../../crates/core)) behind the TypeScript -`Machine` interface. Every target — react, solid, native, opentui — and the connector -consume it unchanged. - -```ts -import { fromWasm } from '@dunky.dev/state-machine-wasm' -import { useMachine } from '@dunky.dev/react-state-machine' -import init, { PaletteMachine } from './pkg/my_machines.js' // built with wasm-bindgen - -await init() - -function CommandPalette(props) { - const { api } = useMachine( - p => fromWasm(new PaletteMachine(p.commands)), - connectCommandPalette, - effects, - props, - ) - // … -} -``` - -On the Rust side, export the machine with one macro: - -```rust -dunky_wasm::export_machine! { - pub struct PaletteMachine(palette::Palette); - new(commands: JsValue) { - // A Result body throws its error from the JS constructor. - dunky_wasm::from_js(commands) - .map(|commands| Machine::with_context(&CONFIG.with(Clone::clone), palette::context(commands))) - } - computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } -} -``` - -## How it works - -- **Events in.** Payload-less events cross as one number (`sendKind`); events with a - payload cross as their fields only (`sendPayload`, when the Rust event derives - `#[event(deserialize)]`). -- **Changes out.** Every call returns a change mask. The adapter keeps a plain JS mirror of - the context (one object, identity stable) and re-reads only the fields the mask names, - so reading context is a property read. -- **Computed values** are cached by the version the Rust side reports. A `computed` - mapping can read another Rust value and remap it — e.g. read indices and map them onto - the host's own objects, so rich values never cross the boundary. -- **Timers** are the host's job: the machine emits start/cancel commands and the adapter - runs them with `setTimeout` (or the `scheduler` you pass). - -One deliberate difference from the TS engine: observers are notified once per call -(send, start, stop, timer), after the whole run-to-completion step. diff --git a/packages/wasm/package.json b/packages/wasm/package.json deleted file mode 100644 index 4119a32..0000000 --- a/packages/wasm/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@dunky.dev/state-machine-wasm", - "version": "0.0.0", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/dunky-dev/state-machine.git", - "directory": "packages/wasm" - }, - "files": [ - "dist", - "src" - ], - "type": "module", - "sideEffects": false, - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "access": "public" - }, - "scripts": { - "build": "tsdown" - }, - "dependencies": { - "@dunky.dev/state-machine": "workspace:*" - } -} diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts deleted file mode 100644 index 3d6e925..0000000 --- a/packages/wasm/src/index.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * `@dunky.dev/state-machine-wasm` — run a Rust (`dunky-core`) machine behind the - * TypeScript `Machine` interface, so every target (react, solid, native, opentui) and - * the connector consume it unchanged. - * - * The Rust side is exported with `dunky_wasm::export_machine!`. Each call returns a - * change mask; this adapter keeps a plain JS mirror of the context and re-reads only - * the fields the mask names, so reads stay property reads. Timers are the host's job: - * the machine emits start/cancel commands and the adapter runs them with `setTimeout`. - * - * One deliberate difference from the TS engine: observers are notified ONCE per call - * (send, start, stop, timer), after the whole run-to-completion step, instead of once - * per change inside it. - */ -import { - makeBroadcast, - makeSelection, - type Machine, - type Select, - type Selection, -} from '@dunky.dev/state-machine' - -/** The protocol every class exported by `dunky_wasm::export_machine!` implements. */ -export interface WasmMachineHandle { - sendKind: (kind: number) => number - sendEvent: (event: unknown) => number - /** Fast path for events with a payload (present when the Rust event derives `deserialize`). */ - sendPayload?: (kind: number, event: unknown) => number - stateIndex: () => number - field: (index: number) => unknown - fields: () => unknown[] - computed: (index: number) => unknown - computedVersion: (index: number) => number - start: () => number - stop: () => number - fireTimer: (id: number) => number - takeCommands: () => Uint32Array - meta: () => WasmMachineMeta -} - -/** The static description a handle reports about its machine type. */ -export interface WasmMachineMeta { - states: string[] - events: string[] - /** Per event type: true when the event carries no payload (sent as one number). */ - unitEvents: boolean[] - fields: string[] - computed: string[] - /** Per state: its tags. */ - tags: string[][] - fieldShift: number - highField: number -} - -/** The host clock. Defaults to the global `setTimeout` / `clearTimeout`. */ -export interface Scheduler { - setTimeout: (fn: () => void, ms: number) => unknown - clearTimeout: (handle: unknown) => void -} - -/** How to produce one computed value on the JS side. */ -export type ComputedMapping = - | ((raw: unknown) => Value) - | { - /** Read this Rust computed value instead (e.g. indices instead of objects). */ - from: string - map: (raw: unknown) => Value - } - -export interface FromWasmOptions { - scheduler?: Scheduler - /** - * Map a raw computed value before it is cached — e.g. read indices that crossed the - * boundary and turn them back into the host's own objects. Runs only when the value - * changed. - */ - computed?: { [K in keyof Computed]?: ComputedMapping } -} - -const STATE_BIT = 1 -const COMMANDS_BIT = 2 -const OP_START = 1 - -interface Prepared { - meta: WasmMachineMeta - eventIndex: Map - computedIndex: Map -} - -// Meta is per machine TYPE: read it once per exported class, not per instance. -const prepared = new WeakMap() - -function prepare(handle: WasmMachineHandle): Prepared { - const type = Object.getPrototypeOf(handle) as object - let p = prepared.get(type) - if (!p) { - const meta = handle.meta() - p = { - meta, - eventIndex: new Map(meta.events.map((name, i) => [name, i])), - computedIndex: new Map(meta.computed.map((name, i) => [name, i])), - } - prepared.set(type, p) - } - return p -} - -const defaultScheduler: Scheduler = { - setTimeout: (fn, ms) => setTimeout(fn, ms), - clearTimeout: handle => clearTimeout(handle as ReturnType), -} - -/** Wrap a wasm machine handle in the `Machine` interface. */ -export function fromWasm< - State extends string, - Context extends object, - Event extends { type: string }, - Computed = Record, ->( - handle: WasmMachineHandle, - options: FromWasmOptions = {}, -): Machine { - const { meta, eventIndex, computedIndex } = prepare(handle) - const scheduler = options.scheduler ?? defaultScheduler - const mappings = (options.computed ?? {}) as Record | undefined> - const { states, fields, unitEvents, fieldShift, highField } = meta - - // The mirror: one plain object, identity stable, mutated in place like the TS engine's. - const context = {} as Record - const initial = handle.fields() - for (let i = 0; i < fields.length; i++) context[fields[i]!] = initial[i] - let stateIdx = handle.stateIndex() - let stateValue = states[stateIdx] as State - let running = false - - // Computed values are cached with the Rust version stamp; `epoch` skips even the - // version check while nothing changed since the last read. - let epoch = 0 - const cachedValue: unknown[] = [] - const cachedVersion: number[] = [] - const cachedEpoch: number[] = [] - const computed = {} as Record - for (const [name, own] of computedIndex) { - const mapping = mappings[name] - const map = typeof mapping === 'function' ? mapping : mapping?.map - const source = typeof mapping === 'object' ? computedIndex.get(mapping.from) : own - if (source === undefined) - throw new Error(`[machine] no computed "${(mapping as { from: string }).from}"`) - const i = own - cachedEpoch[i] = -1 - cachedVersion[i] = -1 - Object.defineProperty(computed, name, { - enumerable: true, - get() { - if (cachedEpoch[i] === epoch) return cachedValue[i] - cachedEpoch[i] = epoch - const version = handle.computedVersion(source) - if (version !== cachedVersion[i]) { - cachedVersion[i] = version - const raw = handle.computed(source) - cachedValue[i] = map ? map(raw) : raw - } - return cachedValue[i] - }, - }) - } - - const broadcast = makeBroadcast() - const startListeners = new Set<() => void>() - const stopListeners = new Set<() => void>() - const timers = new Map() - - function runCommands(): void { - const commands = handle.takeCommands() - for (let i = 0; i < commands.length; i += 3) { - const id = commands[i + 1]! - if (commands[i] === OP_START) { - timers.set( - id, - scheduler.setTimeout( - () => { - timers.delete(id) - apply(handle.fireTimer(id)) - }, - commands[i + 2]!, - ), - ) - } else { - const t = timers.get(id) - if (t !== undefined) scheduler.clearTimeout(t) - timers.delete(id) - } - } - } - - function apply(mask: number): void { - if (mask === 0) return - if (mask & STATE_BIT) { - stateIdx = handle.stateIndex() - stateValue = states[stateIdx] as State - } - let bits = mask >>> fieldShift - let i = 0 - while (bits !== 0) { - if (bits & 1) { - if (i < highField) { - context[fields[i]!] = handle.field(i) - } else { - // The top bit stands for every field from `highField` up. - for (let j = highField; j < fields.length; j++) context[fields[j]!] = handle.field(j) - } - } - bits >>>= 1 - i++ - } - if (mask & COMMANDS_BIT) runCommands() - if (mask & ~COMMANDS_BIT) { - epoch++ - broadcast.notify() - } - } - - const sendPayload = handle.sendPayload?.bind(handle) - const send = (event: Event): void => { - const kind = eventIndex.get(event.type) - if (kind === undefined) return // no such event type: ignored, like an unhandled event - apply( - unitEvents[kind] - ? handle.sendKind(kind) - : sendPayload - ? sendPayload(kind, event) - : handle.sendEvent(event), - ) - } - - const tagSets = meta.tags.map(t => new Set(t)) - const selection = (selector: () => Value): Selection => - makeSelection(selector, onWake => broadcast.add(onWake)) - const select = ((selector: () => Value) => selection(selector)) as Select< - State, - Context, - Computed - > - select.context = key => selection(() => (context as Context)[key]) - select.computed = key => selection(() => (computed as Computed)[key]) - select.state = () => selection(() => stateValue) - - return { - get state() { - return stateValue - }, - get context() { - return context as Context - }, - get computed() { - return computed as Computed - }, - hasTag: tag => tagSets[stateIdx]?.has(tag) ?? false, - matches: name => stateValue === name, - send, - subscribe: listener => broadcast.add(listener), - select, - start() { - if (running) return - running = true - apply(handle.start()) - for (const fn of startListeners) fn() - }, - stop() { - if (!running) return - running = false - apply(handle.stop()) - for (const fn of stopListeners) fn() - }, - onStart(fn) { - startListeners.add(fn) - if (running) fn() - return () => startListeners.delete(fn) - }, - onStop(fn) { - stopListeners.add(fn) - return () => stopListeners.delete(fn) - }, - } -} diff --git a/packages/wasm/tests/adapter.test.ts b/packages/wasm/tests/adapter.test.ts deleted file mode 100644 index 0cc50b8..0000000 --- a/packages/wasm/tests/adapter.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * `fromWasm` against a scripted fake handle — the protocol edges the real machines - * rarely hit: the shared high-field bit, timer commands, computed caching and - * remapping, unknown events, lifecycle listeners. (The real wasm machines are covered - * by the parity tests in packages/demo-wasm.) - */ -import { describe, expect, it, vi } from 'vitest' -import { fromWasm, type Scheduler, type WasmMachineHandle, type WasmMachineMeta } from '../src' - -const FIELD = (i: number) => 1 << (2 + i) -const STATE = 1 -const COMMANDS = 2 - -function fake(meta: Partial = {}) { - const fields = meta.fields ?? ['a', 'b'] - const state = { - index: 0, - values: fields.map(() => 0) as unknown[], - version: 1, - commands: [] as number[], - } - const calls = { field: 0, computed: 0, version: 0 } - let onSend: (kind: number) => number = () => 0 - class Handle implements WasmMachineHandle { - sendKind = vi.fn((kind: number) => onSend(kind)) - sendEvent = vi.fn((_event: unknown) => onSend(-1)) - stateIndex = () => state.index - field = (i: number) => { - calls.field++ - return state.values[i] - } - fields = () => [...state.values] - computed = (i: number) => { - calls.computed++ - return i === 0 ? [2, 0] : 'other' - } - computedVersion = () => { - calls.version++ - return state.version - } - start = () => 0 - stop = () => 0 - fireTimer = vi.fn((_id: number) => 0) - takeCommands = () => { - const out = new Uint32Array(state.commands) - state.commands = [] - return out - } - meta = (): WasmMachineMeta => ({ - states: ['idle', 'busy'], - events: ['go', 'set'], - unitEvents: [true, false], - fields, - computed: ['indices', 'other'], - tags: [[], ['working']], - fieldShift: 2, - highField: 29, - ...meta, - }) - } - const handle = new Handle() - return { handle, state, calls, respond: (fn: (kind: number) => number) => (onSend = fn) } -} - -function manualScheduler() { - const timers = new Map void; ms: number }>() - let next = 0 - const scheduler: Scheduler = { - setTimeout: (fn, ms) => { - timers.set(++next, { fn, ms }) - return next - }, - clearTimeout: handle => timers.delete(handle as number), - } - return { scheduler, timers } -} - -describe('fromWasm', () => { - it('sends payload-less events by kind and the rest as objects; ignores unknown types', () => { - const { handle } = fake() - const m = fromWasm<'idle' | 'busy', { a: number; b: number }, { type: string; v?: number }>( - handle, - ) - m.send({ type: 'go' }) - m.send({ type: 'set', v: 1 }) - m.send({ type: 'nope' }) - expect(handle.sendKind).toHaveBeenCalledWith(0) - expect(handle.sendEvent).toHaveBeenCalledWith({ type: 'set', v: 1 }) - expect(handle.sendKind).toHaveBeenCalledTimes(1) - expect(handle.sendEvent).toHaveBeenCalledTimes(1) - }) - - it('re-reads only the fields the mask names, then notifies once', () => { - const { handle, state, calls, respond } = fake() - const m = fromWasm<'idle' | 'busy', { a: number; b: number }, { type: 'go' }>(handle) - const listener = vi.fn() - m.subscribe(listener) - respond(() => { - state.index = 1 - state.values = [0, 7] - return STATE | FIELD(1) - }) - m.send({ type: 'go' }) - expect(m.state).toBe('busy') - expect(m.hasTag('working')).toBe(true) - expect(m.context).toEqual({ a: 0, b: 7 }) - expect(calls.field).toBe(1) - expect(listener).toHaveBeenCalledTimes(1) - }) - - it('treats the top bit as "every field from highField up"', () => { - const fields = Array.from({ length: 32 }, (_, i) => `f${i}`) - const { handle, state, respond } = fake({ fields }) - const m = fromWasm<'idle', Record, { type: 'go' }>(handle) - respond(() => { - state.values = fields.map((_, i) => (i >= 29 ? i : 0)) - return FIELD(29) - }) - m.send({ type: 'go' }) - expect([m.context.f28, m.context.f29, m.context.f30, m.context.f31]).toEqual([0, 29, 30, 31]) - }) - - it('runs timer commands on the host clock and feeds fired timers back', () => { - const { handle, state, respond } = fake() - const { scheduler, timers } = manualScheduler() - const m = fromWasm<'idle' | 'busy', { a: number; b: number }, { type: 'go' }>(handle, { - scheduler, - }) - respond(() => { - state.commands = [1, 7, 150, 1, 8, 300] - return COMMANDS - }) - m.send({ type: 'go' }) - expect([...timers.values()].map(t => t.ms)).toEqual([150, 300]) - respond(() => { - state.commands = [2, 8, 0] - return COMMANDS - }) - m.send({ type: 'go' }) - expect(timers.size).toBe(1) - timers.get(1)!.fn() - expect(handle.fireTimer).toHaveBeenCalledWith(7) - }) - - it('caches computed values until a change, and remaps a source value once per version', () => { - const { handle, state, calls, respond } = fake() - const own = ['x', 'y', 'z'] - const m = fromWasm< - 'idle', - { a: number; b: number }, - { type: 'go' }, - { indices: string[]; other: string } - >(handle, { - computed: { indices: raw => (raw as number[]).map(i => own[i]!) }, - }) - expect(m.computed.indices).toEqual(['z', 'x']) - expect(m.computed.indices).toBe(m.computed.indices) - expect([calls.version, calls.computed]).toEqual([1, 1]) - - respond(() => FIELD(0)) // a change, same computed version - m.send({ type: 'go' }) - void m.computed.indices - expect([calls.version, calls.computed]).toEqual([2, 1]) - - respond(() => { - state.version = 2 - return FIELD(0) - }) - m.send({ type: 'go' }) - void m.computed.indices - expect(calls.computed).toBe(2) - }) - - it('fires start listeners on start (immediately for late ones) and stop listeners on stop', () => { - const { handle } = fake() - const m = fromWasm<'idle', { a: number; b: number }, { type: 'go' }>(handle) - const early = vi.fn() - const stopped = vi.fn() - m.onStart(early) - m.onStop(stopped) - m.start() - m.start() - const late = vi.fn() - m.onStart(late) - m.stop() - expect([early.mock.calls.length, late.mock.calls.length, stopped.mock.calls.length]).toEqual([ - 1, 1, 1, - ]) - }) -}) diff --git a/packages/wasm/tsconfig.json b/packages/wasm/tsconfig.json deleted file mode 100644 index 3fd6989..0000000 --- a/packages/wasm/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "tests"], - "exclude": ["**/node_modules", "**/dist"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da1edf9..11598fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: benchmark: dependencies: - '@dunky.dev/demo-wasm': - specifier: workspace:* - version: link:../packages/demo-wasm '@dunky.dev/react-state-machine': specifier: workspace:^ version: link:../packages/react @@ -130,18 +127,6 @@ importers: packages/core: {} - packages/demo-wasm: - dependencies: - '@dunky.dev/state-machine': - specifier: workspace:* - version: link:../core - '@dunky.dev/state-machine-wasm': - specifier: workspace:* - version: link:../wasm - '@sandbox/cmdk-core': - specifier: workspace:* - version: link:../../sandbox/shared - packages/dom: dependencies: '@dunky.dev/state-machine-bindings': @@ -169,24 +154,6 @@ importers: specifier: '*' version: 0.85.0(@babel/core@7.29.7)(@types/react@19.2.15)(react@19.2.6) - packages/native-rust: - dependencies: - '@dunky.dev/state-machine-wasm': - specifier: workspace:* - version: link:../wasm - '@sandbox/cmdk-core': - specifier: workspace:* - version: link:../../sandbox/shared - '@ubjs/core': - specifier: 0.31.0-6 - version: 0.31.0-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 - packages/opentui: dependencies: '@dunky.dev/state-machine-bindings': @@ -268,17 +235,8 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.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)) - packages/wasm: - dependencies: - '@dunky.dev/state-machine': - specifier: workspace:* - version: link:../core - sandbox/native: dependencies: - '@dunky.dev/native-rust': - specifier: workspace:^ - version: link:../../packages/native-rust '@dunky.dev/native-state-machine': specifier: workspace:^ version: link:../../packages/native @@ -319,9 +277,6 @@ importers: sandbox/opentui: dependencies: - '@dunky.dev/demo-wasm': - specifier: workspace:^ - version: link:../../packages/demo-wasm '@dunky.dev/opentui-state-machine': specifier: workspace:^ version: link:../../packages/opentui @@ -359,9 +314,6 @@ importers: sandbox/react: dependencies: - '@dunky.dev/demo-wasm': - specifier: workspace:^ - version: link:../../packages/demo-wasm '@dunky.dev/react-state-machine': specifier: workspace:^ version: link:../../packages/react @@ -408,9 +360,6 @@ importers: sandbox/solid: dependencies: - '@dunky.dev/demo-wasm': - specifier: workspace:^ - version: link:../../packages/demo-wasm '@dunky.dev/solid-state-machine': specifier: workspace:^ version: link:../../packages/solid @@ -3428,9 +3377,6 @@ 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==} @@ -6736,10 +6682,6 @@ 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==} @@ -9601,15 +9543,6 @@ 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 @@ -10167,8 +10100,6 @@ 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': @@ -13671,53 +13602,6 @@ 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 @@ -14595,8 +14479,6 @@ 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.tsx b/sandbox/native/app.tsx index 13752ad..b9794fe 100644 --- a/sandbox/native/app.tsx +++ b/sandbox/native/app.tsx @@ -1,25 +1,17 @@ import { useState } from 'react' -import { Alert, Pressable, StyleSheet, Text, View } from 'react-native' +import { Alert, StyleSheet, Text, View } from 'react-native' import { StatusBar } from 'expo-status-bar' -import { commandPaletteMachineConfig, DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -import { rustSource } from './rust-source' - -type Engine = 'ts' | 'rust' export default function App() { const [last, setLast] = useState('—') - const [engine, setEngine] = useState('ts') - const rust = engine === 'rust' ? rustSource : null return ( ⌘K Command Pallete { setLast(c.label) @@ -31,18 +23,6 @@ export default function App() { The same machine + connect runs the terminal (OpenTUI) and React Native versions Last selected: {last} - setEngine(rust ? 'ts' : 'rust')} - > - - Engine: {rust ? 'Rust (JSI)' : 'TypeScript'} ·{' '} - {rustSource === null - ? 'Rust is not built into this app' - : `Switch to ${rust ? 'TypeScript' : 'Rust'}`} - - ) } @@ -59,5 +39,4 @@ const styles = StyleSheet.create({ title: { fontSize: 28, fontWeight: '700', letterSpacing: -0.5, color: '#1c1e26' }, lead: { maxWidth: 460, textAlign: 'center', color: '#5b6172', lineHeight: 24 }, hint: { fontSize: 16, fontWeight: '700', color: '#8990a0' }, - engine: { fontSize: 14, textAlign: 'center', color: '#3142c4' }, }) diff --git a/sandbox/native/command-palette.tsx b/sandbox/native/command-palette.tsx index c059717..dc18518 100644 --- a/sandbox/native/command-palette.tsx +++ b/sandbox/native/command-palette.tsx @@ -5,8 +5,8 @@ import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from import { useMachine } from '@dunky.dev/react-state-machine' import { mergeProps, normalize } from '@dunky.dev/native-state-machine' import { + commandPaletteMachineConfig, type CommandPaletteProps, - type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -15,12 +15,8 @@ import { // elements differ. There's no global keyboard on mobile, so the trigger is a tap // and rows are tapped to execute; arrow-key nav simply isn't part of this // substrate. The machine doesn't care — it just receives `highlight`/`execute`. -// `source` is the TS config or the Rust machine (see app.tsx); nothing else differs. -export function CommandPalette({ - source, - ...props -}: CommandPaletteProps & { source: CommandPaletteSource }) { - const { api, machine } = useMachine(source, connectCommandPalette, [], props) +export function CommandPalette(props: CommandPaletteProps) { + const { api, machine } = useMachine(commandPaletteMachineConfig, connectCommandPalette, [], props) const inputRef = useRef(null) useEffect(() => { diff --git a/sandbox/native/metro.config.js b/sandbox/native/metro.config.js index 55e7008..4746090 100644 --- a/sandbox/native/metro.config.js +++ b/sandbox/native/metro.config.js @@ -22,14 +22,11 @@ config.resolver.nodeModulesPaths = [ // two React instances and "Invalid hook call" errors. Unlike webpack, Metro // doesn't dedupe, and extraNodeModules won't help because the wrong copy // resolves normally; we redirect every `react`/`react-dom` request to the -// app's copy via resolveRequest. Same for `react-native`: pnpm links -// @dunky.dev/native-rust its own copy (a different peer set), and its -// generated TurboModule spec must register with the app's runtime. +// app's copy via resolveRequest. const reactRoot = path.resolve(projectRoot, 'node_modules') -const singleInstance = ['react', 'react-native'] const defaultResolveRequest = config.resolver.resolveRequest config.resolver.resolveRequest = (context, moduleName, platform) => { - if (singleInstance.some(name => moduleName === name || moduleName.startsWith(`${name}/`))) { + if (moduleName === 'react' || moduleName.startsWith('react/')) { return context.resolveRequest( { ...context, originModulePath: path.join(reactRoot, 'noop.js') }, moduleName, diff --git a/sandbox/native/package.json b/sandbox/native/package.json index 3563c8e..eab895a 100644 --- a/sandbox/native/package.json +++ b/sandbox/native/package.json @@ -9,7 +9,6 @@ "android": "expo run:android" }, "dependencies": { - "@dunky.dev/native-rust": "workspace:^", "@dunky.dev/native-state-machine": "workspace:^", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", diff --git a/sandbox/native/rust-source.ts b/sandbox/native/rust-source.ts deleted file mode 100644 index 0591f03..0000000 --- a/sandbox/native/rust-source.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createNativePalette, loadNativeRust } from '@dunky.dev/native-rust' -import type { CommandPaletteSource } from '@sandbox/cmdk-core' - -// The palette on the Rust engine (crates/demo over JSI), or null where this app has no -// Rust module: Expo Go, or a checkout that hasn't run `pnpm ubrn:ios` / `ubrn:android`. -// The TS palette must keep working there. -function rustPalette(): CommandPaletteSource | null { - try { - loadNativeRust() - } catch (error) { - console.warn(error) - return null - } - return props => createNativePalette(props.commands) -} - -export const rustSource = rustPalette() diff --git a/sandbox/native/tsconfig.json b/sandbox/native/tsconfig.json index 9660e95..3a85ccd 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", "rust-source.ts"] + "include": ["index.ts", "app.tsx", "command-palette.tsx"] } diff --git a/sandbox/opentui/package.json b/sandbox/opentui/package.json index 8e27cdb..15a2da3 100644 --- a/sandbox/opentui/package.json +++ b/sandbox/opentui/package.json @@ -5,10 +5,9 @@ "type": "module", "scripts": { "dev": "bun run src/index.tsx", - "dev:rust": "bun run src/index.tsx --engine=rust" + "dev:rust": "bun run src/index.tsx --machine=rust" }, "dependencies": { - "@dunky.dev/demo-wasm": "workspace:^", "@dunky.dev/opentui-state-machine": "workspace:^", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", diff --git a/sandbox/opentui/src/app.tsx b/sandbox/opentui/src/app.tsx index 3f7bb71..dfc0b36 100644 --- a/sandbox/opentui/src/app.tsx +++ b/sandbox/opentui/src/app.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export type Engine = 'ts' | 'rust' +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' -export function App({ engine, source }: { engine: Engine; source: CommandPaletteSource }) { +export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -25,7 +26,7 @@ export function App({ engine, source }: { engine: Engine; source: CommandPalette ⌘K Command Pallete setLast(c.label)} /> - Engine: {engine === 'rust' ? 'Rust (wasm)' : 'TypeScript'} + Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} One state machine drives this ⌘K palette. diff --git a/sandbox/opentui/src/index.tsx b/sandbox/opentui/src/index.tsx index 1d32a32..951fe3b 100644 --- a/sandbox/opentui/src/index.tsx +++ b/sandbox/opentui/src/index.tsx @@ -1,19 +1,19 @@ import { createCliRenderer } from '@opentui/core' import { createRoot } from '@opentui/react' import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' -import { App, type Engine } from './app' +import { App, type Language } from './app' -// `--engine=rust` runs the same palette on the Rust machine (crates/demo → wasm; Bun -// runs WebAssembly). Build the wasm first: `pnpm build:wasm`. -const engine: Engine = process.argv.includes('--engine=rust') ? 'rust' : 'ts' +// `--machine=rust` runs the palette written in Rust (sandbox/shared/rust → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = process.argv.includes('--machine=rust') ? 'rust' : 'ts' let source: CommandPaletteSource = commandPaletteMachineConfig -if (engine === 'rust') { - // Imported only for the Rust engine, so the TS app runs without the wasm build. - const demo = await import('@dunky.dev/demo-wasm/node') - demo.loadDemoNode() - source = props => demo.createPalette(props.commands) +if (language === 'rust') { + // Imported only for the Rust machine, so the TS app loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust-node') + rust.loadRustNode() + source = props => rust.createRustPalette(props.commands) } const renderer = await createCliRenderer() -createRoot(renderer).render() +createRoot(renderer).render() diff --git a/sandbox/react/package.json b/sandbox/react/package.json index f0b09fc..ffa4d77 100644 --- a/sandbox/react/package.json +++ b/sandbox/react/package.json @@ -9,7 +9,6 @@ "preview": "vite preview" }, "dependencies": { - "@dunky.dev/demo-wasm": "workspace:^", "@dunky.dev/react-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 7c004ea..3603605 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export type Engine = 'ts' | 'rust' +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' -export function App({ engine, source }: { engine: Engine; source: CommandPaletteSource }) { +export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -29,9 +30,9 @@ export function App({ engine, source }: { engine: Engine; source: CommandPalette Last selected: {last}

- Engine: {engine === 'rust' ? 'Rust (wasm)' : 'TypeScript'} ·{' '} - - Switch to {engine === 'rust' ? 'TypeScript' : 'Rust'} + Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} + + Switch to {language === 'rust' ? 'TypeScript' : 'Rust'}

diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 09c2957..8ef6694 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' -import { App, type Engine } from './app' +import { App, type Language } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -9,21 +9,22 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -// `?engine=rust` runs the same palette on the Rust machine (crates/demo → wasm). -// Build the wasm first: `pnpm build:wasm`. -const engine: Engine = new URLSearchParams(location.search).get('engine') === 'rust' ? 'rust' : 'ts' +// `?machine=rust` runs the palette written in Rust (sandbox/shared/rust → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = + new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' async function boot(target: HTMLElement) { let source: CommandPaletteSource = commandPaletteMachineConfig - if (engine === 'rust') { - // Imported only for the Rust engine, so the TS page runs without the wasm build. - const demo = await import('@dunky.dev/demo-wasm') - await demo.loadDemo() - source = props => demo.createPalette(props.commands) + if (language === 'rust') { + // Imported only for the Rust machine, so the TS page loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust') + await rust.loadRust() + source = props => rust.createRustPalette(props.commands) } createRoot(target).render( - + , ) } diff --git a/sandbox/react/vite.config.ts b/sandbox/react/vite.config.ts index 6db2aaa..26d9620 100644 --- a/sandbox/react/vite.config.ts +++ b/sandbox/react/vite.config.ts @@ -15,9 +15,6 @@ export default defineConfig({ '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), - // The Rust engine (?engine=rust): the adapter and the demo machines built to wasm. - '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), - '@dunky.dev/demo-wasm': resolve(__dirname, '../../packages/demo-wasm/src'), }, }, }) diff --git a/sandbox/shared/package.json b/sandbox/shared/package.json index 425d0d2..24cd2ff 100644 --- a/sandbox/shared/package.json +++ b/sandbox/shared/package.json @@ -6,7 +6,9 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./rust": "./src/rust.ts", + "./rust-node": "./src/rust-node.ts" }, "peerDependencies": { "@dunky.dev/state-machine": "workspace:^", diff --git a/sandbox/shared/rust/Cargo.toml b/sandbox/shared/rust/Cargo.toml new file mode 100644 index 0000000..fc6692a --- /dev/null +++ b/sandbox/shared/rust/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dunky-sandbox" +description = "The sandbox machines written in Rust (the command palette, a dialog), exported to wasm for every sandbox through fromWasm." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# The JS classes (export_machine!), on for the wasm build (pnpm build:wasm) only, so +# crates reusing the machines (the benchmark) never link a second set of exports. +wasm = ["dep:dunky-wasm", "dep:wasm-bindgen"] + +[dependencies] +dunky-core = { workspace = true } +dunky-wasm = { workspace = true, optional = true } +serde = { workspace = true } +wasm-bindgen = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/demo/src/dialog.rs b/sandbox/shared/rust/src/dialog.rs similarity index 100% rename from crates/demo/src/dialog.rs rename to sandbox/shared/rust/src/dialog.rs diff --git a/sandbox/shared/rust/src/lib.rs b/sandbox/shared/rust/src/lib.rs new file mode 100644 index 0000000..62bedfb --- /dev/null +++ b/sandbox/shared/rust/src/lib.rs @@ -0,0 +1,49 @@ +//! The sandbox machines, written in Rust against `dunky-core`: +//! +//! - [`palette`] — the command palette, a faithful port of `sandbox/shared/src/machine.ts` +//! (computed filtering, logical navigation). +//! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). +//! +//! Built to wasm by `pnpm build:wasm` (`sandbox/shared/rust/pkg`); every sandbox runs the +//! palette on it with `?engine=rust` (`--engine=rust` in the terminal). + +pub mod dialog; +pub mod palette; + +#[cfg(feature = "wasm")] +mod exports { + use dunky_core::{Config, Machine}; + use wasm_bindgen::prelude::*; + + use crate::{dialog, palette}; + + // One config per machine type, shared by every instance (the shape a real app has). + thread_local! { + static PALETTE: Config = palette::config(Vec::new()); + static DIALOG: Config = dialog::config(0); + } + + dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // Malformed commands throw: the host maps result indices onto its own array. + dunky_wasm::from_js::>(commands) + .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) + } + computed { + palette::RESULTS => Vec, + palette::ACTIVE_ID => Option, + palette::RESULT_INDICES => Vec, + } + } + + dunky_wasm::export_machine! { + /// A dialog whose `closing` phase lasts `exitMs`. + pub struct DialogMachine(dialog::Dialog); + new(exit_ms: u32) { + DIALOG.with(|c| Machine::with_context(c, dialog::DialogCtx { exit_ms, open_count: 0 })) + } + computed {} + } +} diff --git a/crates/demo/src/palette.rs b/sandbox/shared/rust/src/palette.rs similarity index 100% rename from crates/demo/src/palette.rs rename to sandbox/shared/rust/src/palette.rs diff --git a/sandbox/shared/src/rust-node.ts b/sandbox/shared/src/rust-node.ts new file mode 100644 index 0000000..74b8006 --- /dev/null +++ b/sandbox/shared/src/rust-node.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs' +import { loadRustSync } from './rust' + +/** Load the sandbox wasm in Node or Bun from the built package. */ +export function loadRustNode(): void { + loadRustSync(readFileSync(new URL('../rust/pkg/dunky_sandbox_bg.wasm', import.meta.url))) +} + +export * from './rust' diff --git a/sandbox/shared/src/rust.ts b/sandbox/shared/src/rust.ts new file mode 100644 index 0000000..c3a9135 --- /dev/null +++ b/sandbox/shared/src/rust.ts @@ -0,0 +1,53 @@ +/** + * The sandbox machines written in Rust (sandbox/shared/rust), wrapped with `fromWasm`. + * Build the wasm first: `pnpm build:wasm`. Load it once — `await loadRust()` in a + * browser, `loadRustSync(bytes)` (or `./rust-node`) in Node and Bun — then build + * machines with the factories below. + */ +import { fromWasm, type Machine } from '@dunky.dev/state-machine' +import init, { DialogMachine, initSync, PaletteMachine } from '../rust/pkg/dunky_sandbox.js' +import type { CommandPaletteMachine } from './machine' +import type { + Command, + CommandPaletteComputed, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteState, +} from './types' + +/** Load the wasm in a browser (fetches `dunky_sandbox_bg.wasm` next to the glue). */ +export async function loadRust(input?: Parameters[0]): Promise { + await init(input) +} + +/** Load the wasm synchronously from its bytes (Node, Bun, tests, workers). */ +export function loadRustSync(bytes: BufferSource): void { + initSync({ module: bytes }) +} + +/** The command palette. `results` crosses the boundary as indices and maps back onto + * the caller's own command objects: Rust owns the ids, rich values stay in JS. */ +export function createRustPalette(commands: Command[]): CommandPaletteMachine { + return fromWasm< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed + >(new PaletteMachine(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as ArrayLike, i => commands[i]!), + }, + }, + }) +} + +export type DialogState = 'closed' | 'open' | 'closing' +export type DialogContext = { exitMs: number; openCount: number } +export type DialogEvent = { type: 'open' | 'close' | 'toggle' } + +/** A dialog whose `closing` phase lasts `exitMs` (an `after` timer on the host clock). */ +export function createRustDialog(exitMs: number): Machine { + return fromWasm(new DialogMachine(exitMs)) +} diff --git a/sandbox/shared/tests/rust.test.ts b/sandbox/shared/tests/rust.test.ts new file mode 100644 index 0000000..72c44e4 --- /dev/null +++ b/sandbox/shared/tests/rust.test.ts @@ -0,0 +1,94 @@ +/** + * The palette written in Rust (through wasm + `fromWasm`) must behave exactly like the + * TS palette for the same event sequence — state, context, computed, and the + * notifications. Needs the wasm build: `pnpm build:wasm`. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { machine } from '@dunky.dev/state-machine' +import { commandPaletteMachineConfig, DEMO_COMMANDS, type CommandPaletteEvent } from '../src' +import { createRustDialog, createRustPalette, loadRustNode } from '../src/rust-node' + +beforeAll(() => loadRustNode()) + +const script: CommandPaletteEvent[] = [ + { type: 'open' }, + { type: 'move', to: 'down' }, + { type: 'move', to: 'down' }, + { type: 'query.set', query: 'go' }, + { type: 'move', to: 'up' }, + { type: 'highlight', index: 99 }, + { type: 'query.set', query: 'zzz' }, + { type: 'move', to: 'down' }, + { type: 'execute' }, + { type: 'query.set', query: 'theme' }, + { type: 'move', to: 'last' }, + { type: 'execute' }, + { type: 'open' }, + { type: 'move', to: 'first' }, + { type: 'execute' }, + { type: 'close' }, +] + +const snapshot = (m: ReturnType) => ({ + state: m.state, + query: m.context.query, + activeIndex: m.context.activeIndex, + lastExecuted: m.context.lastExecuted, + results: m.computed.results.map(c => c.id), + activeId: m.computed.activeId, +}) + +describe('Rust palette', () => { + it('matches the TS palette step by step, notification for notification', () => { + const ts = machine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) + const rs = createRustPalette(DEMO_COMMANDS) + const seen = { ts: [] as unknown[], rs: [] as unknown[] } + ts.subscribe(() => seen.ts.push(snapshot(ts))) + rs.subscribe(() => seen.rs.push(snapshot(rs))) + ts.start() + rs.start() + for (const event of script) { + ts.send(event) + rs.send(event) + expect(snapshot(rs)).toEqual(snapshot(ts)) + } + expect(seen.rs).toEqual(seen.ts) + }) + + it('maps result indices back onto the caller’s own command objects', () => { + const rs = createRustPalette(DEMO_COMMANDS) + rs.send({ type: 'open' }) + expect(rs.computed.results[0]).toBe(DEMO_COMMANDS[0]) + }) + + it('throws on malformed props instead of guessing', () => { + expect(() => + createRustPalette([{ id: 1 } as unknown as (typeof DEMO_COMMANDS)[number]]), + ).toThrow('[machine] bad argument: invalid type') + }) +}) + +describe('Rust dialog', () => { + it('runs the `after` timer on the host clock, and cancels it when the state is left', () => { + vi.useFakeTimers() + try { + const d = createRustDialog(150) + d.start() + d.send({ type: 'open' }) + expect(d.hasTag('visible')).toBe(true) + d.send({ type: 'close' }) + vi.advanceTimersByTime(149) + expect(d.state).toBe('closing') + vi.advanceTimersByTime(1) + expect(d.state).toBe('closed') + expect(d.context.openCount).toBe(1) + + d.send({ type: 'open' }) + d.send({ type: 'close' }) + d.send({ type: 'open' }) // back to open before the exit delay ends + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json index 7ff6265..e9ac5b6 100644 --- a/sandbox/solid/package.json +++ b/sandbox/solid/package.json @@ -9,7 +9,6 @@ "preview": "vite preview" }, "dependencies": { - "@dunky.dev/demo-wasm": "workspace:^", "@dunky.dev/solid-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 76da964..21c879a 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -2,9 +2,10 @@ import { createSignal } from 'solid-js' import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export type Engine = 'ts' | 'rust' +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' -export function App(props: { engine: Engine; source: CommandPaletteSource }) { +export function App(props: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = createSignal('—') return ( @@ -29,9 +30,9 @@ export function App(props: { engine: Engine; source: CommandPaletteSource }) { Last selected: {last()}

- Engine: {props.engine === 'rust' ? 'Rust (wasm)' : 'TypeScript'} ·{' '} - - Switch to {props.engine === 'rust' ? 'TypeScript' : 'Rust'} + Machine written in {props.language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} + + Switch to {props.language === 'rust' ? 'TypeScript' : 'Rust'}

diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index cab6a12..c896744 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -1,6 +1,6 @@ import { render } from '@solidjs/web' import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' -import { App, type Engine } from './app' +import { App, type Language } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -8,19 +8,20 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -// `?engine=rust` runs the same palette on the Rust machine (crates/demo → wasm). -// Build the wasm first: `pnpm build:wasm`. -const engine: Engine = new URLSearchParams(location.search).get('engine') === 'rust' ? 'rust' : 'ts' +// `?machine=rust` runs the palette written in Rust (sandbox/shared/rust → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = + new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' async function boot(target: HTMLElement) { let source: CommandPaletteSource = commandPaletteMachineConfig - if (engine === 'rust') { - // Imported only for the Rust engine, so the TS page runs without the wasm build. - const demo = await import('@dunky.dev/demo-wasm') - await demo.loadDemo() - source = props => demo.createPalette(props.commands) + if (language === 'rust') { + // Imported only for the Rust machine, so the TS page loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust') + await rust.loadRust() + source = props => rust.createRustPalette(props.commands) } - render(() => , target) + render(() => , target) } void boot(root) diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index 1af9020..eea5af4 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -18,9 +18,6 @@ export default defineConfig({ '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), - // The Rust engine (?engine=rust): the adapter and the demo machines built to wasm. - '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), - '@dunky.dev/demo-wasm': resolve(__dirname, '../../packages/demo-wasm/src'), }, }, }) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 00c6813..80669c2 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -1,29 +1,46 @@ #!/usr/bin/env bash -# Build the demo machines to wasm for the JS adapter, benchmark and sandboxes. +# Build the wasm modules: +# - packages/core/wasm: the Rust engine + the runtime for TS-authored machines, which +# @dunky.dev/state-machine loads synchronously from the inlined bytes (wasm/bytes.js). +# - sandbox/shared/rust/pkg: the sandbox machines written in Rust (palette, dialog). +# - benchmark/rust/pkg: the benchmark machines written in Rust. # Needs: rustup target wasm32-unknown-unknown, wasm-bindgen-cli matching the crate # version (see Cargo.toml), and optionally wasm-opt (binaryen) for the size pass. set -euo pipefail cd "$(dirname "$0")/.." -OUT=packages/demo-wasm/pkg -WASM=target/wasm32-unknown-unknown/wasm/dunky_demo_wasm.wasm +# The features rustc enables by default for wasm32 (Rust >= 1.82); binaryen must accept them. +WASM_OPT_FLAGS=(-Oz --strip-debug --strip-producers + --enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int + --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue) -cargo build -p dunky-demo-wasm --target wasm32-unknown-unknown --profile wasm -# --target web: one ES module that works in browsers (await init()) and Node (initSync). -# --weak-refs: instances are freed by a FinalizationRegistry, so JS never calls free(). -wasm-bindgen --target web --weak-refs --out-dir "$OUT" "$WASM" +# build [cargo args...] +build() { + local crate=$1 lib=$2 out=$3 + shift 3 + cargo build -p "$crate" --target wasm32-unknown-unknown --profile wasm "$@" + # --target web: one ES module for browsers (init) and Node/Bun/tests (initSync). + # --weak-refs: instances are freed by a FinalizationRegistry. + # --reference-types: JS values cross as externrefs, so passing one costs no JS call. + wasm-bindgen --target web --weak-refs --reference-types --out-dir "$out" "target/wasm32-unknown-unknown/wasm/$lib.wasm" + if command -v wasm-opt >/dev/null 2>&1; then + wasm-opt "${WASM_OPT_FLAGS[@]}" "$out/${lib}_bg.wasm" -o "$out/${lib}_bg.wasm" + else + echo "wasm-opt not found: skipping the size pass (brew install binaryen)" >&2 + fi + local raw gz + raw=$(wc -c < "$out/${lib}_bg.wasm" | tr -d ' ') + gz=$(gzip -9c "$out/${lib}_bg.wasm" | wc -c | tr -d ' ') + echo "$crate: ${raw} B raw, ${gz} B gzip" +} -if command -v wasm-opt >/dev/null 2>&1; then - # The features rustc enables by default for wasm32 (Rust >= 1.82); binaryen must accept them. - wasm-opt -Oz --strip-debug --strip-producers \ - --enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int \ - --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue \ - "$OUT/dunky_demo_wasm_bg.wasm" -o "$OUT/dunky_demo_wasm_bg.wasm" -else - echo "wasm-opt not found: skipping the size pass (brew install binaryen)" >&2 -fi +build dunky-core-wasm dunky_core_wasm packages/core/wasm +# Inline the bytes so the package starts synchronously (no `await init()`). +{ + printf '// Generated by scripts/build-wasm.sh — the wasm of crates/core-wasm, base64.\n' + printf 'export const WASM = "%s"\n' "$(base64 < packages/core/wasm/dunky_core_wasm_bg.wasm | tr -d '\n')" +} > packages/core/wasm/bytes.js +printf 'export declare const WASM: string\n' > packages/core/wasm/bytes.d.ts -raw=$(wc -c < "$OUT/dunky_demo_wasm_bg.wasm" | tr -d ' ') -gz=$(gzip -9c "$OUT/dunky_demo_wasm_bg.wasm" | wc -c | tr -d ' ') -glue=$(gzip -9c "$OUT/dunky_demo_wasm.js" | wc -c | tr -d ' ') -echo "wasm: ${raw} B raw, ${gz} B gzip · JS glue: ${glue} B gzip" +build dunky-sandbox dunky_sandbox sandbox/shared/rust/pkg --features wasm +build dunky-benchmark dunky_benchmark benchmark/rust/pkg diff --git a/tsconfig.build.json b/tsconfig.build.json index 7165b70..ba5a59a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,7 +3,6 @@ "references": [ { "path": "./tsconfig.json" }, { "path": "./packages/core" }, - { "path": "./packages/wasm" }, { "path": "./packages/dom" }, { "path": "./packages/react" }, { "path": "./packages/solid" }, @@ -11,7 +10,6 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, - { "path": "./packages/demo-wasm" }, { "path": "./benchmark" } ] } diff --git a/tsconfig.json b/tsconfig.json index 0f11fdb..8436124 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,6 @@ "strict": true, "paths": { "@dunky.dev/state-machine": ["./packages/core/src"], - "@dunky.dev/state-machine-wasm": ["./packages/wasm/src"], "@dunky.dev/react-state-machine": ["./packages/react/src"], "@dunky.dev/solid-state-machine": ["./packages/solid/src"], "@dunky.dev/native-state-machine": ["./packages/native/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 51d9e8b..937d196 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ // choice. Keep in sync with the publish set in .changeset/config.json. workspace: [ 'packages/core', - 'packages/wasm', 'packages/dom', 'packages/react', 'packages/solid', diff --git a/vitest.config.ts b/vitest.config.ts index 117b2dc..5940b5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ name: 'default', globals: false, environment: 'node', - include: ['packages/**/tests/**/*.test.{ts,tsx}'], + include: ['packages/**/tests/**/*.test.{ts,tsx}', 'sandbox/shared/tests/**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], }, }, From caa438348ab64e65d62bde7e3a97b526a2c18ddd Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:09:43 +0200 Subject: [PATCH 11/28] docs: the Rust engine, both machine languages, package SPECs - 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 --- AGENTS.md | 16 +-- ARCHITECTURE.md | 92 ++++++++++------- CONTRIBUTING.md | 37 +++---- packages/core/README.md | 50 +++++++++- packages/core/SPEC.md | 88 +++++++++++++---- packages/react/SPEC.md | 208 +++++++++++++++++++++++++++++++++++++++ packages/solid/SPEC.md | 212 ++++++++++++++++++++++++++++++++++++++++ sandbox/README.md | 20 ++-- 8 files changed, 630 insertions(+), 93 deletions(-) create mode 100644 packages/react/SPEC.md create mode 100644 packages/solid/SPEC.md diff --git a/AGENTS.md b/AGENTS.md index 030105b..6876572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,10 @@ The working contract for anyone — human or agent — modifying code in this repo. This file is the canonical entry point: read it first, every time. 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. +state machines — in TypeScript (`packages/core`) or Rust (`crates/core`) — +running on one Rust engine, rendered anywhere through thin per-substrate +targets (`react`, `solid`, `native`, `opentui`), with a benchmark suite, +per-substrate sandboxes, and the docs website alongside. ## Preflight @@ -27,6 +27,7 @@ editing files in that scope — it overrides anything here for that scope | Scope | Path | What it is | | --------- | ------------- | ----------------------------------------------------------------------------------------- | | Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals | +| Crates | `crates/**` | The Rust engine and its bindings (wasm for JS, JSI for React Native) | | Benchmark | `benchmark/` | Perf suite comparing against competitor libraries | | Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | | Website | `website/` | The docs site | @@ -41,9 +42,10 @@ These are invariants, not preferences. Violating them breaks the layered model: - **Core never imports a substrate.** No React, no React Native, no - DOM, no `window`, no `document`. `packages/core/*` is pure - TypeScript. If you reach for a substrate API in `core/`, stop — the - code belongs in a target. + DOM, no `window`, no `document`. `packages/core/*` is TypeScript over + the Rust engine (`crates/core`, compiled to wasm), and the engine is + just as substrate-free. If you reach for a substrate API in `core/`, + stop — the code belongs in a target. - **Targets never reimplement state.** Targets read from the machine via its connector. They do not fork the state graph, mirror context, or shadow transitions. If a target needs new state, the state goes in diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index febaa50..f89f1fb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,7 +12,7 @@ runtime: same machine, same behavior, same accessibility intent, different rende The host +----------------------------------------------------------------------+ | packages/core | -| No runtime render — pure JS, runs anywhere | +| No runtime render — TS over the Rust engine (wasm), runs anywhere | | states, events, context, guards, actions, effects, select | +----------------------------------------------------------------------+ | consumed by every target @@ -42,16 +42,15 @@ The host This repo is the **engine** — the agnostic machine plus its per-target bridges. The components that consume it (and their style/codegen pipeline) live elsewhere. -> **Status: experimental, but it compiles and runs.** The `packages/core` engine -> is a stable plain-mutation kernel (no signals; see -> [`packages/core/README.md`](./packages/core/README.md)). The -> suite is green and `tsc` is clean. It's an in-progress exploration — the API may -> still move — not a 1.0. +> **Status: experimental, but it compiles and runs.** The engine is a Rust +> plain-mutation kernel compiled to wasm (no signals; see +> [The engine](#the-engine)). The suite is green and `tsc` is clean. It's an +> in-progress exploration — the API may still move. ## Three layers -The repo splits in three layers. **`core/`** is the agnostic side — pure JS, -no renderer. It says _what_ behavior is: states, transitions, context, guards, +The repo splits in three layers. **`core/`** is the agnostic side — TS over the +Rust engine, no renderer. It says _what_ behavior is: states, transitions, context, guards, actions. Nothing in `core/` knows that React or the DOM exists. **`shared/`** is the cross-target side — `shared/bindings` owns the @@ -93,14 +92,16 @@ Zag, whose machines read props directly.) ## Project structure -| File / location | What it owns | -| -------------------------------- | ----------------------------------------------------------------- | -| `packages/core/` | State-machine engine (plain-mutation kernel) | -| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | -| `packages/shared/utils/` | mergeProps, composeHandlers | -| `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | -| `crates/core/` | Rust port of the engine (experimental, same SPEC) | -| `crates/wasm/`, `packages/wasm/` | Rust machine → wasm → the TS `Machine` interface | +| File / location | What it owns | +| --------------------------- | ---------------------------------------------------------------------- | +| `packages/core/` | The TS API: `machine()`, `fromWasm()`, connector, the `Machine` facade | +| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | +| `packages/shared/utils/` | mergeProps, composeHandlers | +| `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | +| `crates/core/` | The engine (Rust; implements the core SPEC) | +| `crates/wasm/` | The JS protocol: runtime for TS machines, `export_machine!` for Rust | +| `crates/core-wasm/` | The wasm `packages/core` ships (built into `packages/core/wasm`) | +| `crates/uniffi/` | React Native over JSI (in progress) | ## The map @@ -174,29 +175,52 @@ whether it needs props/platform or not: platform listener is per-target. On accept it `send()`s a plain event the machine already understands. -## The Rust core (experimental) +## The engine -`crates/core` (`dunky-core`) is a Rust port of the engine. It implements the same -[SPEC](packages/core/SPEC.md); its tests are ported from `packages/core/tests`. -Machines are typed Rust (`#[derive(State, Event, Context)]`), the core owns no clock -(`after` timers are commands the host runs), and every call reports a change mask. +The engine is Rust: `crates/core` (`dunky-core`), compiled to WebAssembly. It runs the +graph — the run-to-completion queue, transition resolution, entry/exit order, the +effects lifecycle, `after` timers, watchers and computed bookkeeping — and implements +[`packages/core/SPEC.md`](packages/core/SPEC.md); its Rust tests are ported from +`packages/core/tests`. A machine is written in TypeScript or in Rust, and both run on it: ``` -crates/core (Rust machines: engine + config + computed) - | - +-- crates/wasm --> .wasm + JS glue --> packages/wasm (fromWasm) - | | - | v - | the TS Machine interface: react, solid, native, opentui - | - +-- crates/uniffi --> native code + JSI (React Native; Hermes has no wasm) +TS machine: machine(config) Rust machine: dunky-core types + | | + | compiled to numbers + JS tables | export_machine! (your crate) + v v +crates/core-wasm (inside the package) your wasm module + | | + +------------------+-------------------+ + | one protocol (crates/wasm): events in by kind, + | a notify per change, timers on the host clock + v + packages/core: the Machine facade (machine() / fromWasm()) + | + v + the targets: react, solid, native, opentui ``` -A target never knows which engine runs: `useMachine` takes a config (TS engine) or a -ready machine (`toMachine`), and `fromWasm(handle)` returns the same `Machine` -interface. The adapter mirrors context into one plain JS object and re-reads only the -fields the change mask names; observers are notified once per call. Build the demo -machines with `pnpm build:wasm`; the spike numbers live in `benchmark/tests/wasm.ts`. +- **TS machines.** `machine(config)` compiles the config once: states and event types + become numbers, the user code (guards, actions, effects, delays, computed) becomes + tables. Rust calls back into them by index; the context stays a plain JS object, + and each `setContext` reports its change mask so Rust drives watchers and computed + staleness. The wasm is inlined in the package and starts on the first `machine()`. +- **Rust machines.** Typed Rust (`#[derive(State, Event, Context)]`), exported to JS + with `dunky_wasm::export_machine!` and wrapped with `fromWasm`: the facade keeps a + JS mirror of the context and re-reads only the fields each notify names. +- **One facade.** Both kinds share the `Machine` facade and the host functions + (`notify`, `startTimer`, `cancelTimer`), which take numbers and return status codes. + JS passes the facade with each call; Rust holds no JS object between calls, so only JS + references keep a machine alive, and a pending timer holds its machine, like a JS + timer holds `this`. +- **Few crossings.** A TS machine announces its own context writes in JS; a run of + actions, a guard walk, and a state change with its entry actions each cost one call + into JS. +- **React Native.** Hermes has no WebAssembly; `crates/uniffi` is the start of a native + engine over JSI. + +The sandbox and the benchmark write machines in Rust too (`sandbox/shared/rust`, +`benchmark/rust`); `pnpm build:wasm` builds every module. ## Vocabulary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a958d0e..aa6a4a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,10 @@ corepack enable pnpm install ``` -The Rust core (`crates/`) and the wasm demo machines need a Rust toolchain. The -typecheck and the wasm tests read the generated `packages/demo-wasm/pkg`, so build it -once after cloning (and after changing a crate): +The engine is Rust (`crates/`), so every workspace task needs a Rust toolchain: the +core package loads the generated `packages/core/wasm`, and the sandbox and benchmark +load their own Rust machines. Build them once after cloning (and after changing a +crate): ```bash rustup target add wasm32-unknown-unknown @@ -26,18 +27,18 @@ pnpm build:wasm ## Commands -| Command | What it does | -| ------------------------------ | --------------------------------------------------------------- | -| `pnpm test` | Full test suite, watch mode | -| `pnpm test:ci` | Full test suite, once | -| `pnpm typecheck` | `tsc -b` across the whole workspace | -| `pnpm lint` | `oxlint` | -| `pnpm format` / `format:check` | `oxfmt` | -| `pnpm build` | Build every publishable package | -| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | -| `pnpm build:wasm` | Build the Rust demo machines to wasm (`packages/demo-wasm/pkg`) | -| `pnpm test:rust` | `cargo test` across the Rust workspace | -| `pnpm lint:rust` | `cargo clippy` (warnings are errors) + `cargo fmt --check` | +| Command | What it does | +| ------------------------------ | ------------------------------------------------------------------------ | +| `pnpm test` | Full test suite, watch mode | +| `pnpm test:ci` | Full test suite, once | +| `pnpm typecheck` | `tsc -b` across the whole workspace | +| `pnpm lint` | `oxlint` | +| `pnpm format` / `format:check` | `oxfmt` | +| `pnpm build` | Build every publishable package | +| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | +| `pnpm build:wasm` | Build the engine and the Rust machines to wasm (`packages/core/wasm`, …) | +| `pnpm test:rust` | `cargo test` across the Rust workspace | +| `pnpm lint:rust` | `cargo clippy` (warnings are errors) + `cargo fmt --check` | Target one package or one file instead of the whole workspace: @@ -54,9 +55,9 @@ pnpm -C sandbox/opentui dev # terminal — needs Bun pnpm -C sandbox/native dev # Expo — needs a simulator or device ``` -The React and Solid sandboxes run the same palette on the Rust core with -`?engine=rust`; the opentui one with `pnpm -C sandbox/opentui dev:rust`. Build the -wasm first (`pnpm build:wasm`). +The React and Solid sandboxes run the palette written in Rust with `?machine=rust`; +the opentui one with `pnpm -C sandbox/opentui dev:rust`. Build the wasm first +(`pnpm build:wasm`). ## Filing an issue diff --git a/packages/core/README.md b/packages/core/README.md index bca25cd..f1e0669 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,8 +4,10 @@ A tiny, **renderer-agnostic state-machine engine** for building UI component logic once and running it anywhere. It owns _behavior_, states, transitions, side-effects, derived state, and knows nothing about the render environment. -It's pure JavaScript: it runs in any JS runtime (browser, Node, the React -Native JS thread), but not in native platform code (e.g. Swift/Kotlin). +The engine is Rust, compiled to WebAssembly and inlined in the package: it starts +synchronously, with no `await init()`, in any JS runtime with WebAssembly (browsers, +Node, Bun, Deno). React Native's Hermes has none, so it does not run there yet. You +write machines in TypeScript, as below, or [in Rust](#machines-written-in-rust). ```ts import { machine, act } from '@dunky.dev/state-machine' @@ -112,9 +114,9 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers. -In practice that's **up to ~7× the event throughput** of the alternatives, flat -memory as context grows wide, and surgical re-renders that wake only the rows -that actually changed. +In practice that's **~6× the event throughput** of the alternatives (~10× once +thousands of observers watch one machine), flat memory as context grows wide, and +surgical re-renders that wake only the rows that actually changed. **[See the benchmark →](../../benchmark/README.md)** for what's measured, how, and the full per-scenario tables vs. XState and Zag. @@ -914,6 +916,44 @@ const off = tooltipStore.subscribe(s => m.send({ type: 'activeChanged', openId: --- +## Machines written in Rust + +A machine can also be written in Rust, with the [`dunky-core`](../../crates/core) +crate, and exported to JS with one macro from [`dunky-wasm`](../../crates/wasm): + +```rust +dunky_wasm::export_machine! { + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands).map(|c| Machine::with_context(&CONFIG.with(Clone::clone), palette::context(c))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +} +``` + +Build it with `wasm-bindgen`, then wrap an instance with `fromWasm`. The result is a +`Machine` like any other: the connector, `useMachine` and every target consume it +unchanged, with the same notifications as a TS machine. + +```ts +import { fromWasm } from '@dunky.dev/state-machine' +import init, { PaletteMachine } from './pkg/machines.js' // your wasm-bindgen output + +await init() +const palette = fromWasm( + new PaletteMachine(commands), + // Optional: rebuild a computed value from another one, e.g. indices that crossed + // the boundary back onto your own objects. Runs only when the value changed. + { computed: { results: { from: 'resultIndices', map: i => i.map(n => commands[n]) } } }, +) +``` + +`context` is a plain JS mirror of the Rust context, with a stable identity; each change +re-reads only the fields it touched. + +--- + ## Putting it together ```ts diff --git a/packages/core/SPEC.md b/packages/core/SPEC.md index 23c9821..3fdf5d2 100644 --- a/packages/core/SPEC.md +++ b/packages/core/SPEC.md @@ -16,19 +16,21 @@ anywhere. The map below is the whole engine — every feature and how they connect. ``` - config - the whole behavior, declared once - | - | initial state + seed context - | states, each with: on, entry, exit, effects, after, tags - | any-state handlers: on - | derived data: computed - | data-reactions: watch - | reusable + named: guards, actions, effects, delays - | guards compose (and / or / not); actions set context or run one-of - | - | build - v - machine - a live service (current state, context, computed) + config - the whole behavior, declared once a machine written in Rust + | (dunky-core), exported to + | initial state + seed context its own wasm module + | states, each with: on, entry, exit, effects, after, tags | + | any-state handlers: on | + | derived data: computed | + | data-reactions: watch | + | reusable + named: guards, actions, effects, delays | + | guards compose (and / or / not) | + | actions set context or run one-of | + | | + | build | wrap + v | + machine - a live service (current state, context, computed) <-----+ + | both kinds run on the Rust engine | | lifecycle: [ stopped ] --start--> [ running ] --stop--> | @@ -83,18 +85,34 @@ place rather than rebuilt on every change; there are no signals and no immutable snapshots. Performance is a constraint of the spec, not an afterthought (see [Performance guarantees](#performance-guarantees)). +The engine is **Rust**, compiled to WebAssembly (`crates/core`). A machine is +written in TypeScript (a config) or in Rust (`dunky-core`); both run on that +one engine, honor this spec, and give the same service, so a target cannot +tell them apart. For a TS machine, Rust runs the graph and calls back into +the user code, and the context stays a plain JS object. The protocol between +the engine and this package is +[`crates/wasm/SPEC.md`](../../crates/wasm/SPEC.md)'s; authoring in Rust is +[`crates/core/SPEC.md`](../../crates/core/SPEC.md)'s. + ## Scope & boundaries **In scope.** The state graph and its runtime; context and derived (computed) data; guards, actions, effects, timed (`after`) transitions, and data-reactions (`watch`); the observation surface; the view boundary -(the connector and its reactions); multi-machine composition; and a -cross-instance reactive cell. +(the connector and its reactions); multi-machine composition; a +cross-instance reactive cell; and the service over a machine written in +Rust. + +**Runtime.** The package needs WebAssembly (browsers, Node, Bun, Deno). The +engine is inlined and starts synchronously the first time a machine is +built — importing the package costs nothing, and there is no asynchronous +initialization step. React Native's Hermes has no WebAssembly, so the +package does not run there yet. **Out of scope — invariants, not preferences.** -- **No substrate.** Core is pure TypeScript. No web, native, or terminal - API; no DOM, `window`, `document`, or platform globals. Reaching for one +- **No substrate.** Core is TypeScript over the Rust engine. No web, native, + or terminal API; no DOM, `window`, `document`, or platform globals. Reaching for one means the code belongs in a target, not here. - **The machine never sees props.** A machine is pure behavior. It does not read the consumer's props; props enter only at the edge (context @@ -120,6 +138,11 @@ and bridged to a view by a **connector**. changes; and reusable **named** guards, actions, effects, and delays that transitions reference by name. - Building a config yields a **service** that is live but stopped. +- Wrapping a machine written in Rust yields the same kind of service — see + [Machines written in Rust](#machines-written-in-rust). +- Anything that builds a service (a target's lifecycle hook) accepts a + **source**: a config, which it builds into a stopped service, or a ready + service, which it uses as is. - A **connector** turns the service's current state into the surface a view renders and fires the consumer's callbacks — without the machine ever knowing those callbacks exist. @@ -188,6 +211,9 @@ method names it exposes. transitions exist only while running. - Lifecycle transitions are observable, so a bridge can wire and tear down external subscriptions in step with start and stop. +- A service lives as long as JS references it — a consumer, a dispatch + handle an effect kept, or a pending timer, which holds its service like a + JS timer holds `this`. The engine never keeps a service alive on its own. ### Run-to-completion @@ -198,7 +224,7 @@ method names it exposes. - Deferred work (a watcher's actions) shares the same queue and ordering. - A runaway feedback loop — e.g. a watcher that writes the field it watches, or actions that dispatch in a cycle — is detected and aborted - during development with a diagnostic, rather than hanging. + with a diagnostic, rather than hanging. ### Transition resolution @@ -267,6 +293,8 @@ The order of operations on a matched transition is fixed and observable: always see live values. - Updating context is a **shallow merge** that notifies observers **only if** at least one field actually changed; a no-op update is silent. +- The update is the only write the engine observes: mutating the object + directly notifies nobody, runs no watcher, and refreshes no derived value. - The common case — setting context — has a write-sugar that composes several patches in order, each an object or a function of the current params, with later function-patches seeing earlier writes. @@ -334,6 +362,27 @@ The order of operations on a matched transition is fixed and observable: select a single field, the current state, or a composite over anything. - Neither form fires on subscribe; both hand back a way to detach. +### Machines written in Rust + +A machine written in Rust, in its own wasm module, is wrapped into a +service. The wrapped service honors this whole contract, and adds: + +- **The same notifications.** Observers are notified once per change, in + order, exactly as for the same machine written in TypeScript. +- **A context mirror.** The context is one plain object with a stable + identity, updated in place: each change re-reads only the fields it + touched. It is read-only from JS; the machine's own actions write it. +- **Derived values served by Rust.** A derived value keeps its identity + until it changes. A **mapping** may rebuild it on the JS side from a Rust + value — its own or another derived value — and runs only when that value + changed; for example, indices that crossed the boundary map back onto the + host's own objects, so rich values never cross. +- **Events by type.** An event carries its payload by its fields; a + malformed payload is an error at dispatch. An event type the machine does + not know is ignored, like an unhandled event. +- **One wrapper.** A machine instance is wrapped once; wrapping it again is + an error. + ### Development vs. production - Referencing a name (guard, action, effect, or delay) with no matching @@ -446,3 +495,6 @@ chaining array methods, or allocating closures inside loops. ignored, never applied. - Referencing a missing named implementation is a hard error in development and a degraded no-op in production. +- An event type the machine does not know is ignored, for both kinds of + machine. +- A machine written in Rust is wrapped once; a second wrap is an error. diff --git a/packages/react/SPEC.md b/packages/react/SPEC.md new file mode 100644 index 0000000..5d5b986 --- /dev/null +++ b/packages/react/SPEC.md @@ -0,0 +1,208 @@ +# SPEC - `@dunky.dev/react-state-machine` + +## Overview + +The React target runs one core machine inside one React component. The +behavior lives in the core machine and in the component's `connect`. This +package adds only what React needs: a lifecycle hook, a selector hook, a +translator from bindings to DOM props, and a prop merger. + +``` + machine source connect component effects props + (config or machine) (pure) (static list) (each render) + | | | | + v v v v + +-------------------------------------------------------------------+ + | the lifecycle hook - one call per component instance | + | | + | first render ..... build the machine + connector, once | + | mount ............ start the machine; unmount: stop it | + | after a render ... push the current props into the connector | + | any change ....... re-render from the connector's surface | + | each effect ...... re-run it when its named props change | + +-------------------------------------------------------------------+ + | | + | surface (api) | machine + v v + translator: bindings --> DOM props the selector hook: a leaf + | re-renders on one value + v + +``` + +## Intent + +Bridge one machine into React with as little React as possible. A +component's behavior files stay free of React: the machine, `connect`, and +the component effects are plain values. The lifecycle hook makes every +React hook call for them. + +The bridge makes three promises. The machine is built once and lives as +long as the component. The view renders from a stable surface, so React +never tears and never loops. A leaf can subscribe to one value, so a change +wakes only the readers of that value (`O(readers)`). + +## Scope & boundaries + +**In scope.** The lifecycle hook, the selector hook, the translator from +bindings to React DOM props, and the prop merger. + +**Out of scope — invariants, not preferences.** + +- **No state of its own.** The target reads the machine through the + connector. It does not mirror context, fork the state graph, or shadow + transitions. New state goes in core. +- **The machine never sees props.** The first props reach the source + factory once. After that, props reach the connector and the component + effects, never the machine. See + [the core rule](../core/SPEC.md#the-machine-never-sees-props). +- **Platform quirks live here, as component effects.** An effect that needs + props or a platform API (a DOM `keydown`, a `ResizeObserver`) is a + component effect. Core makes the decision; the effect only listens and + sends a plain event. Props-free, platform-free effects stay in the core + config (see + [Two homes for a side-effect](../../ARCHITECTURE.md#two-homes-for-a-side-effect)). +- **The hooks use React only.** They touch no DOM API, because other React + renderers reuse them: the React Native target re-exports both hooks, and + the OpenTUI sandbox pairs them with the OpenTUI translator. Only the + translator and the prop merger are DOM-specific. + +## The behavior contract + +These are the guarantees the bridge gives. They describe what it does, not +the names it exports. + +### The machine source + +- The first argument is a factory. The hook calls it with the first + render's props. It returns a **config** or a **ready machine** (for + example, a Rust machine wrapped with `fromWasm`). +- A config is built into a stopped machine. A ready machine is used as is: + the hook returns that same instance. A target cannot tell the two kinds + apart (see [core Intent](../core/SPEC.md#intent)). +- The hook owns the machine's lifecycle in both cases. So a ready machine + must come fresh from the factory, one per component instance. Sharing one + machine between components is not supported: when one instance unmounts, + it stops the machine for all of them. + +### Lifecycle + +- **Build once.** The machine and its connector are built on the first + render and never rebuilt. A rebuild would lose state. +- **Start on mount, stop on unmount.** A StrictMode mount, unmount, and + mount runs start, stop, start. This is safe because a stop keeps state + and a restart boots from the current state (see + [core Lifecycle](../core/SPEC.md#lifecycle)). +- **The hook never destroys the connector.** The connector shares the + machine's lifetime. A destroy on unmount would break the StrictMode + remount, which reuses the same memoized connector. +- **Reactions follow the machine.** The connector wires its reactions on + start and removes them on stop (see + [the connector](../core/SPEC.md#the-view-boundary--the-connector)). So a + prop callback fires only while the component is mounted, and never on + subscribe. + +### Props + +- The first render's props seed the machine (through the factory) and the + connector. +- After each render commits, the hook pushes the current props into the + connector. It never does this during render: a store write mid-render + notifies React's external-store subscription and loops. +- The connector ignores props that are shallow-equal to the current props. + A re-render with equal props keeps the same surface. + +### Rendering + +- The component re-renders on every machine change and on every real props + change. React reads the connector's memoized surface through its + external-store subscription. +- The surface keeps its identity between changes, so React never tears and + never loops. +- The hook returns the surface and the machine. The view renders from the + surface. The machine is for sending events and for the selector hook. + +### Component effects + +- A component effect is a pair: a setup function of the machine and the + props, which can return a cleanup; and the names of the props it depends + on. The names are typed against the props, so a typo does not compile. +- Each effect runs as its own React effect. Setup runs after mount. Cleanup + runs on unmount. +- An effect re-runs (cleanup, then setup) only when one of its own named + props changes. A change to any other prop does not re-run it, not even a + new callback identity. +- The effect list must be a static module constant. Each entry is one React + hook call, and React forbids a changing hook count. +- An effect sees the props of the render that last ran it. If it reads a + prop later (for example, a callback inside a listener), that prop must be + in its dep list. If not, the effect reads a stale value. +- The machine that an effect gets is live: it can read the current state. + +### Fine-grained selection + +- The selector hook subscribes a leaf to one value read from a machine. The + leaf re-renders only when that value changes: by identity, or by a + supplied equality. +- A change elsewhere in the machine, or an event that changes nothing, does + not re-render the leaf. A change wakes only the leaves whose value + changed. +- A fresh selector closure on each render is correct. The hook always + evaluates the latest closure, so a selector can close over a prop. +- A selector that returns a fresh object or array on each call must supply + an equality. With one, the hook returns the same object until a real + change. Without one, every read looks new and the leaf re-renders in a + loop. + +### Translating bindings + +- The translator turns the connector's agnostic bindings into React DOM + props. It is the only place where a binding becomes a DOM prop. +- Handlers take their DOM names: `onPress` becomes `onClick`, + `onValueChange` becomes `onChange`, and `onDoublePress` becomes + `onDoubleClick`. The other handlers keep their names. +- A handler whose agnostic payload differs from the DOM event (value + change, wheel, scroll, scroll end) is wrapped, so the consumer's handler + gets the agnostic payload. Other handlers pass through unchanged. +- An ARIA-derived attribute becomes `aria-*`, and its value does not change + (booleans, the `'mixed'` tristate, enums). `focusable` becomes `tabIndex`: + `0` when true, `-1` when false. +- An `undefined` value is dropped. An unknown key (`data-*`) passes through. +- The DOM can express every key in the vocabulary, so this target drops + none. The shared conformance suite checks where each key lands. +- The DOM half that the Solid target also uses (the `aria-*` map and the + payload adapters) lives in `@dunky.dev/state-machine-dom`. This package + adds only React's own prop names. + +### Merging consumer props + +- The prop merger combines the consumer's props with the component's + translated props for one element. +- Handlers (`on` followed by an uppercase letter) chain, consumer first. If + the consumer's handler marks the event `defaultPrevented`, the library + handler is skipped. +- `className` concatenates when both sides are strings: one space between, + edges trimmed. +- `style`: when both sides set it, both apply, and the library wins on a + conflicting key. +- Every other key: the library wins, because the component owns its + semantics (`id`, `role`, `aria-*`). A key that only the consumer sets + stays. + +## Edge cases that carry design meaning + +- In StrictMode development, React runs the build twice on the first render + and keeps one result. The other machine is never started. So the factory + must only build and return. +- A props change reaches the surface one render later. The render that + carries the new props still shows the old surface. The connector's wake + then schedules a render with the new surface. + +## Reference + +- [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) — the attribute + vocabulary that the translator maps to `aria-*`. See + [ACCESSIBILITY.md](../../ACCESSIBILITY.md). +- React [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore) + and [`StrictMode`](https://react.dev/reference/react/StrictMode) — the + contracts that the lifecycle hook relies on. diff --git a/packages/solid/SPEC.md b/packages/solid/SPEC.md new file mode 100644 index 0000000..1a01341 --- /dev/null +++ b/packages/solid/SPEC.md @@ -0,0 +1,212 @@ +# SPEC - `@dunky.dev/solid-state-machine` + +## Overview + +The Solid target runs one core machine inside one Solid component. It is a +first-class target built on Solid 2.0's own reactivity, not a port of the +React bridge. It has the same four parts as the React target (a lifecycle +hook, a selector hook, a translator from bindings to DOM props, and a prop +merger), and it takes the same machine, `connect`, and component effects. + +``` + machine source connect component effects reactive props + (config or machine) (pure) (static list) (Solid proxy) + | | | | + v v v v + +-------------------------------------------------------------------+ + | the lifecycle hook - the component body calls it once | + | | + | call ........... build the machine + connector | + | settled ........ start the machine; disposal: stop it | + | a prop change .. push a plain copy of the props | + | a wake ......... reconcile the new surface into a Solid store | + | each effect .... run it; re-run it when its named props change | + +-------------------------------------------------------------------+ + | | + | api (store proxy) | machine + v v + translator: bindings --> DOM props the selector hook: an accessor + | that changes on one value + v + +``` + +## Intent + +Bridge one machine into Solid with Solid's own tools: a store that mirrors +the surface, two-phase effects, and the owner's settle and cleanup. There is +no re-render and no external-store hook. + +The bridge is fine-grained by default. A read of one surface field +subscribes to that field only, so an unrelated change never touches it. A +component effect has the same shape on every target, so it is written once +and runs unchanged on React and Solid. + +## Scope & boundaries + +**In scope.** The lifecycle hook, the selector hook, the translator from +bindings to Solid DOM props, and the prop merger. + +**Out of scope — invariants, not preferences.** + +- **No state of its own.** The store mirrors the connector's surface. The + hook writes to it only from the connector, on each wake. New state goes + in core. +- **The machine never sees props.** The first props reach the source + factory once. After that, props reach the connector and the component + effects, never the machine. See + [the core rule](../core/SPEC.md#the-machine-never-sees-props). +- **Platform quirks live here, as component effects.** Core makes the + decision; the effect only listens and sends a plain event. Props-free, + platform-free effects stay in the core config (see + [Two homes for a side-effect](../../ARCHITECTURE.md#two-homes-for-a-side-effect)). +- **Solid 2.0 only.** The peer range is `solid-js` `^2.0.0-rc.1`. Solid 1.x + is not supported. Solid 2.0 removed the 1.x surface (`solid-js/store`, + one-phase effects, `onMount`), and 1.x lacks the root exports that this + package imports. One code path cannot serve both majors. + +## The behavior contract + +These are the guarantees the bridge gives. They describe what it does, not +the names it exports. + +### The machine source + +- The first argument is a factory. The hook calls it once, with the + component's props. It returns a **config**, which is built into a stopped + machine, or a **ready machine** (for example, a Rust machine wrapped with + `fromWasm`), which is used as is: the hook returns that same instance. +- The hook owns the machine's lifecycle in both cases. So a ready machine + must come fresh from the factory, one per component instance. Sharing one + machine between components is not supported. + +### Lifecycle + +- **Build once.** A Solid component body runs once, so a plain call builds + once. A later prop change never rebuilds the machine. +- **Start when rendering settles. Stop on disposal.** A stop keeps state + (see [core Lifecycle](../core/SPEC.md#lifecycle)). +- **The hook never destroys the connector.** The connector and the machine + share the component's lifetime and are collected together. +- **Reactions follow the machine.** The connector wires them on start and + removes them on stop. A reaction never fires on subscribe. + +### Props + +- The hook takes Solid's reactive props object. +- The connector gets a plain copy of the props, never the live proxy. The + connector compares each update with the props it holds. A held proxy + always equals a fresh copy of itself, because its getters already return + the new values. So the connector would never wake. +- A tracked effect reads every prop. When any prop changes, it pushes a new + copy to the connector. There is no manual dep list. The connector ignores + a copy that is shallow-equal to the current props. + +### The surface — a fine-grained store + +- The hook mirrors the connector's surface into a Solid store. On each + connector wake, it reconciles the new surface into the store. Only the + leaves that changed notify their readers. +- A read of one field, in JSX or in a tracked scope, subscribes to that + field only. A change to another field does not re-run that reader. +- The returned `api` is the store proxy. Do not destructure it: that reads + each value once and drops reactivity. Read a field where you use it. +- Function leaves (handlers, part getters such as `getItemProps`) stay + callable and live across updates, although `connect` builds new closures + on each wake. + +### Component effects + +- A component effect is the same pair as on every target: a setup function + of the machine and the props, which can return a cleanup; and the names + of the props it depends on, typed against the props. +- Each effect is one two-phase Solid effect. The tracking phase reads + exactly the named props. The setup runs in the untracked phase. +- So the dep list is the whole re-run contract. A prop that the setup only + reads never becomes a hidden dependency. A change to a prop outside the + list does not re-run the effect. +- Setup runs after mount, and cleanup on disposal. On a dep change, the + previous cleanup runs before the next setup, so a listener never stacks. +- The effect gets the hook's reactive props object, not a copy. A read + inside a listener sees the current value, and the read does not re-run + the effect. +- The machine that an effect gets is live: it can read the current state. + +### Fine-grained selection + +- The selector hook returns a Solid accessor for one value read from a + machine. The accessor changes only when that value changes: by identity, + or by a supplied equality. +- A change elsewhere in the machine, or an event that changes nothing, does + not update the accessor. A change wakes only the readers whose value + changed. +- A selected function comes back by identity and is never called. A Solid + 2.0 signal treats a function argument as a compute, so the hook seeds and + writes the signal through the function form. +- The subscription belongs to the owner. When the owner is disposed, the + machine stops evaluating the selector. +- An object selection must supply an equality. If not, every machine change + pushes a new value, even when the content is equal. +- `api` is already fine-grained. The selector hook is for a leaf that + tracks one value of a machine it does not own, such as many rows. + +### Translating bindings + +- The translator turns the agnostic bindings into DOM props as Solid's JSX + expects them. It uses the same vocabulary and the same shared DOM half + (`@dunky.dev/state-machine-dom`) as the React target. Only Solid's own + names and values differ: + - `onValueChange` becomes `onInput`, because Solid's `onChange` fires only + on commit. + - `onDoublePress` becomes `onDblClick`. + - `focusable` becomes the lowercase `tabindex`: `0` when true, `-1` when + false. It is not a boolean, because `false` must still let script focus + the element. + - A boolean ARIA value becomes the string `"true"` or `"false"`. Solid 2.0 + renders a boolean attribute as present or absent, but ARIA states are + literal tokens. Other values (`'mixed'`, enums, numbers) do not change. +- Handlers get native events, not synthetic events. The payload adapters + read the same fields on both, so a wrapped handler (value change, wheel, + scroll, scroll end) gets the agnostic payload. +- An `undefined` value is dropped. An unknown key (`class`, `data-*`) passes + through. This target drops no vocabulary key; the shared conformance + suite checks where each key lands. + +### Merging consumer props + +- Handlers chain, consumer first. If the consumer's handler marks the event + `defaultPrevented`, the library handler is skipped. +- `class` (not `className`) concatenates when both sides are strings: one + space between, edges trimmed. +- `style` merges into one object when both sides are objects. The library + wins on a conflicting key. If either side is a string, the library's + value wins. +- Every other key: the library wins. A key that only the consumer sets + stays. +- This is not Solid's own `mergeProps`, which merges reactive prop objects. + +### Solid 2.0 timing + +- Solid 2.0 commits signal writes, effects, and DOM updates on the + microtask queue. JSX readers always settle. +- After an event, a plain read of `api` sees the new value at once. A + selector accessor read in the same tick returns the previous value until + the queue flushes. So tests call `flush()` from `solid-js` before they + check the DOM or an accessor. +- JSX types and `render` come from `@solidjs/web` + (`"jsxImportSource": "@solidjs/web"`), not from `solid-js/web`. + +## Edge cases that carry design meaning + +- Function leaves stay live across updates. The peer floor is `2.0.0-rc.1` + because the `reconcile` in `2.0.0-rc.0` corrupted them. +- After disposal, an event can still move the machine, but no prop callback + fires. + +## Reference + +- [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) — the attribute + vocabulary that the translator maps to `aria-*`. See + [ACCESSIBILITY.md](../../ACCESSIBILITY.md). +- [Solid](https://github.com/solidjs/solid) `2.0.0-rc.1` — the peer floor + and the reactive model that this bridge is built on. diff --git a/sandbox/README.md b/sandbox/README.md index c6d0996..d1f70f6 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -44,21 +44,19 @@ pnpm -C sandbox/native dev # then press i / a, or scan the QR All four consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. -## The same palette on the Rust core +## The same palette, written in Rust -Every sandbox can also run the palette from `crates/demo` (the Rust port of -`shared/src/machine.ts`) — same `connect()`, same markup, only the machine differs: +Both palettes run on the Rust engine. By default the sandboxes run the one written in +TypeScript (`shared/src/machine.ts`); each web and terminal sandbox can also run its +Rust twin (`shared/rust`, wrapped with `fromWasm`) — same `connect()`, same markup, +only the machine's source language differs: ```bash pnpm build:wasm # once: Rust → wasm (see CONTRIBUTING.md) -pnpm -C sandbox/react dev # then open http://localhost:5173/?engine=rust -pnpm -C sandbox/solid dev # then open http://localhost:5173/?engine=rust +pnpm -C sandbox/react dev # then open http://localhost:5173/?machine=rust +pnpm -C sandbox/solid dev # then open http://localhost:5173/?machine=rust pnpm -C sandbox/opentui dev:rust # Bun runs the wasm - -pnpm -C packages/native-rust ubrn:ios # React Native: Rust → native code over JSI -pnpm -C packages/native-rust ubrn:android -pnpm -C sandbox/native ios # then tap "Engine: TypeScript" to switch ``` -Without those builds the TS engine still runs everywhere; the Rust switch is the only -thing that needs them. +React Native has no WebAssembly (Hermes), so the native sandbox waits for the engine +over JSI. From 3c710ca1b173c7ef83e35f071f407940514be20b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:09:51 +0200 Subject: [PATCH 12/28] perf(benchmark): TS vs Rust machines, engine heap in memory, new numbers - 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 --- README.md | 19 ++-- benchmark/README.md | 151 ++++++++++++++++--------- benchmark/tests/memory.ts | 13 ++- website/src/content/docs/benchmark.mdx | 56 +++++---- 4 files changed, 150 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 44d33c1..5503127 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ **Define a behavior once. Render it anywhere.** 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. +Dunky splits them. You describe behavior as a **state machine** — in TypeScript or +in Rust — that knows nothing about the environment, and a thin per-substrate layer +plugs it into a runtime. Both run on one engine, written in Rust and compiled to +WebAssembly. The same machine drives any render in a JS runtime. Same states, same transitions, same accessibility intent. Only the render differs. @@ -38,13 +39,13 @@ canvas board, a game HUD. There the cost of each transition and the memory per machine, multiplied by thousands, is what decides whether you hold the frame. The engine is built for it: -| At scale (thousands of machines) | Dunky | XState | Zag | -| -------------------------------- | ---------: | -----: | ------: | -| Event throughput (ops/s) | **11.6 M** | 1.6 M | n/a ᵃ | -| Memory / machine, 2-field (KB) | **3.9** | 3.6 | 8.9 | -| Memory / machine, 64-field (KB) | **4.4** | 4.1 | **134** | +| At scale (thousands of machines) | Dunky | XState | Zag | +| -------------------------------- | --------: | -----: | ------: | +| Event throughput (ops/s) | **9.1 M** | 1.6 M | n/a ᵃ | +| Memory / machine, 2-field (KB) | **3.5** | 3.6 | 8.9 | +| Memory / machine, 64-field (KB) | **4.1** | 4.1 | **134** | -→ **~7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the +→ **~5.7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the **[benchmark README](./benchmark/README.md)**. **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. diff --git a/benchmark/README.md b/benchmark/README.md index f0fe616..18794c5 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -4,9 +4,11 @@ A performance harness for `@dunky.dev/state-machine`. It measures the engine's hot paths in isolation and compares the runnable parts against [XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). -Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures -vary by machine, Node version, and thermal state — **run it yourself** — but the -ranking and the scaling shape are what hold. +Numbers below are the average of two runs (Node 24, Apple M5 Pro). The engine is Rust, compiled to +wasm: the **Dunky** rows run machines written in TypeScript on it, and +[section 8](#8-ts-machines-vs-rust-machines-testswasmts) compares the same machines +written in Rust. Absolute figures vary by machine, Node version, and thermal state — +**run it yourself** — but the ranking and the scaling shape are what hold. > Want to _see_ it instead of read tables? **`pnpm benchmark:demo`** opens a live > side-by-side where each engine drives a grid of real per-cell machines under a @@ -95,11 +97,11 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 658.5 K | 437.8 K | n/a ᵃ | -| 1000 | 56.8 K | 49.2 K | n/a ᵃ | -| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | +| 100 | 653.3 K | 456.9 K | n/a ᵃ | +| 1000 | 56.1 K | 48.7 K | n/a ᵃ | +| 5000 | **14.6 K** | 1.4 K | n/a ᵃ | -→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's +→ Roughly par at small N, but Dunky **~10× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. **B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The @@ -109,8 +111,8 @@ model gets for free and a coarse bus has to work to ignore. | Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | -| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | +| 1000 | **5.1 M** | 902.9 K | n/a ᵃ | +| 5000 | **3.0 M** | 711.7 K | n/a ᵃ | → Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. @@ -118,14 +120,14 @@ cells); the value-deduping bus skips waking observers entirely. **C. Throughput — single machine, one event.** Per-transition cost with no selection scaling — the raw `send` price. -| Single machine, one event | ops/sec | -| ------------------------- | ---------: | -| Dunky | **11.6 M** | -| XState (raw) | 1.6 M | -| XState (diffed) | 1.6 M | -| Zag | n/a ᵃ | +| Single machine, one event | ops/sec | +| ------------------------- | --------: | +| Dunky | **9.1 M** | +| XState (raw) | 1.6 M | +| XState (diffed) | 1.6 M | +| Zag | n/a ᵃ | -→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, +→ Dunky pushes **~5.7× the events/sec** of XState. Context is mutated in place, so a transition allocates nothing; XState builds a fresh snapshot per event. ## 2. Compose / synced machines (`tests/compose.ts`) @@ -142,12 +144,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | -| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | -| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | +| 2 | 8.6 M | 9.1 M | n/a ᶠ | n/a ᶠ | +| 10 | 8.2 M | 8.4 M | n/a ᶠ | n/a ᶠ | +| 50 | 6.7 M | 7.5 M | n/a ᶠ | n/a ᶠ | -→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. +→ Cross-region coordination stays in the **~6.7–9.1 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~18–23% going from 2 to 50. > A third "chain" sub-test (a sync rule that `send()`s downstream every change) was > removed: under a tight loop it shows superlinear slowdown. That's a real @@ -163,13 +165,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z | Scenario | Dunky (ops/sec) | XState | Zag | | ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | +| Cached read (no change) | **55.6 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 5.4 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 3.3 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 960.5 K | n/a ᶠ | n/a ᵃ | -→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means +→ A cached read is **~55.6 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~5.4 M/sec — read-key tracking means you only pay the recompute when an input you actually read changes. ## 4. Engine hot paths (`tests/engine.ts`) @@ -181,17 +183,18 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full -state transitions with entry/exit actions, and effect boot/cleanup every -transition all stay in the same order of magnitude as a bare `send`. +| Guard fallthrough — 2 candidates | 4.3 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.2 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 3.9 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 6.2 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 4.4 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 9.5 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 6.9 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~3.9–9.5 M ops/sec**: a 32-candidate guard walk +(evaluated in one call from the engine into JS), full state transitions with +entry/exit actions, and effect boot/cleanup every transition all stay within +~2.3× of a bare `send`. ## 5. Construction cost (`tests/construct.ts`) @@ -201,40 +204,40 @@ warmed first. | Build + start | Dunky (µs/machine) | XState | Zag | | ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.58 | 1.35 | 5.06 | +| 10 000 | 1.43 | 1.10 | 4.86 | → Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.2× faster than Zag's per-field reactive cells. +~1.3× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.4× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) Build 5000 machines, hold them live, report retained heap per machine (`heapMB()` -double-GCs before sampling). Two context widths — **thin** (2 fields) and **fat** +double-GCs before sampling; for Dunky, the JS heap plus the engine's wasm heap). Two context widths — **thin** (2 fields) and **fat** (64 fields) — because the whole point of the plain-object model is that memory stays ~flat in field count. Rows below are the **written** mode (one `hit` each — the footprint a churny app actually pays). | Context | Dunky (KB/machine) | XState | Zag | | -------- | -----------------: | -----: | ------: | -| 2-field | 3.85 | 3.61 | 8.92 | -| 64-field | 4.35 | 4.10 | **134** | +| 2-field | 3.53 | 3.61 | 8.92 | +| 64-field | 4.05 | 4.10 | **134** | → Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with the data you store, not with a per-field cell. **Zag is the contrast**: one reactive -cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than +cell per field balloons the 64-field context to ~134 KB/machine — **~33× more** than Dunky. **Idle vs written.** Dunky owns its context copy from construction and mutates -it in place forever, so its idle and written footprints match by design — while a -lazy-copy scheme steps up once writes start: +it in place forever, so its idle and written footprints nearly match by design — +while a lazy-copy scheme steps up once writes start: | 64-field, 5000 machines | Dunky | XState | Zag | | ----------------------- | ----: | -----: | --: | -| Idle (never written) | 4.35 | 3.55 | 130 | -| Written (1 event each) | 4.35 | 4.10 | 134 | +| Idle (never written) | 4.02 | 3.55 | 130 | +| Written (1 event each) | 4.05 | 4.10 | 134 | -→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, +→ Dunky idle ≈ written; XState's first `assign` allocates a per-actor context, so its written row grows. ## 7. React rendering (`tests/rendering/`) @@ -250,21 +253,57 @@ List of 1000 rows: | Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | | -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 3.6 | **2.4** | -| Dunky/selector | 2 | 4.9 | 3.8 | -| xstate/selector | 2 | 3.7 | 4.3 | -| zag/instance | 2 | 3.9 | n/a ᵃ | -| naive (anti-pattern) | **980** | 4.5 | 38.3 | +| Dunky/instance | **2** | 3.8 | **2.4** | +| Dunky/selector | 2 | 4.7 | 3.8 | +| xstate/selector | 2 | 3.8 | 4.5 | +| zag/instance | 2 | 4.3 | n/a ᵃ | +| naive (anti-pattern) | **980** | 5.2 | 37.4 | → Every properly-set-up engine wakes only the **2** rows that changed (vs. the naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× +~16× the wall time). Among the surgical strategies Dunky re-renders **~1.9× faster than XState**. Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the microtask-batched `send` can't be timed under a synchronous `flushSync` loop, so only its row-count is comparable. +## 8. TS machines vs Rust machines (`tests/wasm.ts`) + +Every machine runs on the Rust engine, but you can write it in TypeScript (a config) +or in Rust (`dunky-core`, wrapped with `fromWasm`). Each row runs the **same machine** +both ways — `benchmark/rust` ports the TS machines 1:1. **Rust, raw** is the bare +wasm handle with no facade: the floor. A call into wasm costs the same as a JS call +(~18 ns), so the gaps are the work, not the boundary. + +| Scenario | TS machine (ops/sec) | Rust machine | Rust, raw | +| ----------------------------------------- | -------------------: | -----------: | --------: | +| One event, observed field | **9.4 M** | 6.1 M | 12.8 M | +| One event, unobserved field | **9.4 M** | 5.8 M | 12.6 M | +| State churn — exit+entry every event | **7.2 M** | 6.1 M | 9.1 M | +| Guard fallthrough — 8 candidates | **7.8 M** | 5.7 M | — | +| Palette — type + read, 11 commands | **703.7 K** | 400.7 K | — | +| Palette — type + read, 1,000 commands | **13.1 K** | 8.1 K | — | +| Palette — type + read, 10,000 commands | **1.3 K** | 786 | — | +| Construct + start one machine (µs / mach) | **4.72** | 5.00 | 2.01 | + +→ In JavaScript, a machine written in TS handles **~1.2–1.6× the events** of its Rust +twin. Its context already lives in JS, while a Rust machine pushes each change back +across the boundary: a notify, then a read per changed field to refresh the JS +context mirror. The raw handle (no subscribers, no mirror) shows the engine itself: +~12.8 M events/sec. + +→ Heavy derived data favors TS too: the palette filters **~1.6–1.8× faster** as a TS +machine, because its filter runs as JIT-compiled JS and its results are already JS +values, while the Rust machine serializes its results on each change. Crossing as +indices mapped back onto your own objects (the rows above) beats crossing whole +objects by ~22% (6.6 K at 1,000 commands). + +→ Rust machines are for reuse: the same machine runs in a Rust program and over JSI, +where there is no JS mirror to keep. In React ([section 7](#7-react-rendering-testsrendering)) +both render alike: a Rust machine per row re-renders the 1000-row list in **2.4 ms**, +the same as a TS machine, and one behind `useSelector` in 3.6 ms (TS: 3.8 ms). + ## Where this actually matters The loads here (and in the demo) are deliberately extreme to force the engines to diff --git a/benchmark/tests/memory.ts b/benchmark/tests/memory.ts index 9de82bd..6fa83aa 100644 --- a/benchmark/tests/memory.ts +++ b/benchmark/tests/memory.ts @@ -4,6 +4,8 @@ * The central claim — "flat memory at 5k scale, even with fat context" (the * trading-view case) — asserted in the README with no script until now. This * measures it: build N machines, hold them live, report retained heap / machine. + * For core that is the JS heap PLUS the engine's own heap: the Rust engine runs in + * wasm, whose memory a JS heap sample does not see. * * Two context widths, because the whole point of the plain-object model is that * memory should stay ~flat in FIELD COUNT (no per-field reactive cell): @@ -19,6 +21,8 @@ import { createActor, createMachine as createXMachine, assign } from 'xstate' import { createMachine as createZagMachine } from '@zag-js/core' import { VanillaMachine } from '@zag-js/vanilla' import { machine } from '@dunky.dev/state-machine' +// Not public API: the same module instance the package uses (one engine per process). +import { engineHeapBytes } from '../../packages/core/src/wasm' import { heapMB } from '../report' const FIELDS = { thin: 2, fat: 64 } as const @@ -129,6 +133,8 @@ type Sendable = { send: (e: { type: string }) => void } interface Engine { build: (fields: number) => unknown + /** Memory the JS heap does not see (MB), e.g. core's wasm engine. */ + outsideHeapMB?: () => number // Fire one `hit` so a write actually happens. Core owns its context copy from // construction (idle ≈ written by design); for XState the first assign() // allocates a per-actor context, so its written footprint grows — the @@ -139,7 +145,7 @@ interface Engine { const sendHit = (m: unknown) => (m as Sendable).send({ type: 'hit' }) const ENGINES: Record = { - core: { build: buildCore, write: sendHit }, + core: { build: buildCore, write: sendHit, outsideHeapMB: () => engineHeapBytes() / 1024 / 1024 }, xstate: { build: buildXstate, write: sendHit }, // bind a shared per-width config so we measure machine overhead, not config dup zag: { @@ -155,14 +161,15 @@ const ENGINES: Record = { } function measureOnce(engine: Engine, N: number, fields: number, write: boolean): number { - const before = heapMB() + const sample = () => heapMB() + (engine.outsideHeapMB?.() ?? 0) + const before = sample() const hold: unknown[] = Array.from({ length: N }) for (let i = 0; i < N; i++) { const m = engine.build(fields) if (write) engine.write(m) hold[i] = m } - const after = heapMB() + const after = sample() // keep `hold` reachable across the sample so it isn't collected if ((hold as unknown[]).length !== N) throw new Error('unreachable') return after - before // MB retained by the N machines diff --git a/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index 325c2e3..81a2933 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -1,6 +1,6 @@ --- title: Benchmark -description: Throughput and memory vs. XState and Zag. +description: Throughput and memory vs. XState and Zag, and machines written in TS vs Rust. head: - tag: style content: | @@ -29,23 +29,23 @@ and run anywhere. The speed is what that model gives you for free. ## How these numbers were measured -These were run on a **MacBook Pro (M1, 32 GB)**. Your absolute numbers will differ, so **[run it yourself](https://github.com/dunky-dev/state-machine/blob/main/benchmark/README.md#running)** and see. +These were run on a **MacBook Pro (M5 Pro, 64 GB)** with Node 24. The engine is Rust, compiled to WebAssembly: the Dunky columns run machines written in TypeScript on it, and [a section below](#machines-written-in-ts-vs-rust) runs the same machines written in Rust. Your absolute numbers will differ, so **[run it yourself](https://github.com/dunky-dev/state-machine/blob/main/benchmark/README.md#running)** and see. **Why a column is sometimes blank.** The three engines don't run the same way, so not every test fits all three. The cell is marked, the same way everywhere: - **`n/a ᵃ`**: _async._ Zag's `send` is microtask-batched, so it can't run in a synchronous ops/sec or `flushSync` loop, only where it runs synchronously (construction, memory, React rendering). - **`n/a ᶠ`**: _no equivalent feature._ The engine has no first-class primitive for that scenario (e.g. XState has no lazy/memoized `computed`), so there's nothing comparable to time. -**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 7×–31× gaps are the point. +**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 6×–33× gaps are the point. ## Overview -| | Dunky | XState | Zag | -| ------------------------- | ---------------: | -----: | -----: | -| Event throughput | **11.6 M ops/s** | 1.6 M | n/a ᵃ | -| Memory, 2-field context | **3.9 KB** | 3.6 KB | 8.9 KB | -| Memory, 64-field context | **4.4 KB** | 4.1 KB | 134 KB | -| Re-render wall, 1000 rows | **2.4 ms** | 4.3 ms | n/a ᵃ | +| | Dunky | XState | Zag | +| ------------------------- | --------------: | -----: | -----: | +| Event throughput | **9.1 M ops/s** | 1.6 M | n/a ᵃ | +| Memory, 2-field context | **3.5 KB** | 3.6 KB | 8.9 KB | +| Memory, 64-field context | **4.1 KB** | 4.1 KB | 134 KB | +| Re-render wall, 1000 rows | **2.4 ms** | 4.5 ms | n/a ᵃ | ᵃ Zag's `send` is microtask-batched; can't run in a synchronous ops/sec loop. @@ -53,9 +53,9 @@ These were run on a **MacBook Pro (M1, 32 GB)**. Your absolute numbers will diff A single machine, one event, tight loop: -| | Dunky | XState | Zag | -| ------- | ---------: | -----: | ----: | -| ops/sec | **11.6 M** | 1.6 M | n/a ᵃ | +| | Dunky | XState | Zag | +| ------- | --------: | -----: | ----: | +| ops/sec | **9.1 M** | 1.6 M | n/a ᵃ | XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing. @@ -65,8 +65,8 @@ Change a field no observer has selected. The dedup layer re-evaluates and value- | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 1 000 | **5.4 M** | 987.9 K | -| 5 000 | **4.4 M** | 844.1 K | +| 1 000 | **5.1 M** | 902.9 K | +| 5 000 | **3.0 M** | 711.7 K | XState's `actor.subscribe` is coarse: it fires on every snapshot change. To match Dunky's behavior you'd add a differ in the listener, which is what the `xstate` column already does, for a fair comparison. @@ -77,9 +77,9 @@ affected observer" cycles complete per second (higher is better): | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 100 | 658.5 K | 437.8 K | -| 1 000 | 56.8 K | 49.2 K | -| 5 000 | **15.5 K** | 1.3 K | +| 100 | 653.3 K | 456.9 K | +| 1 000 | 56.1 K | 48.7 K | +| 5 000 | **14.6 K** | 1.4 K | Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one. @@ -89,8 +89,8 @@ The whole point of the plain-object model: memory grows with your data, not with | Context width | Dunky | XState | Zag | | ------------- | -----: | -----: | -----: | -| 2 fields | 3.9 KB | 3.6 KB | 8.9 KB | -| 64 fields | 4.4 KB | 4.1 KB | 134 KB | +| 2 fields | 3.5 KB | 3.6 KB | 8.9 KB | +| 64 fields | 4.1 KB | 4.1 KB | 134 KB | Going 2 → 64 fields costs Dunky **~0.5 KB/machine**. Zag allocates one reactive cell per field, so a 64-field context grows to **134 KB/machine**. @@ -100,9 +100,23 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 1.58 | **1.35** | 5.06 | +| µs / machine | 1.43 | **1.10** | 4.86 | -XState cold-starts ~1.2× faster; Zag is ~3.2× slower than both. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). +XState cold-starts ~1.3× faster; Zag is ~3.4× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). + +## Machines written in TS vs Rust + +Every machine runs on the Rust engine, but you can write it in TypeScript or in Rust (wrapped with `fromWasm`). Each row runs the **same machine** both ways: + +| Scenario (ops/s) | TS machine | Rust machine | +| ---------------------------------- | ---------: | -----------: | +| One event | **9.4 M** | 6.1 M | +| State churn (exit + entry actions) | **7.2 M** | 6.1 M | +| Guard fallthrough, 8 candidates | **7.8 M** | 5.7 M | +| Command palette, 1,000 commands | **13.1 K** | 8.1 K | +| Command palette, 10,000 commands | **1.3 K** | 786 | + +In JavaScript, a TS machine is faster: its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. In React both render alike: a 1000-row list re-renders in 2.4 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. ## Where this matters From 3424d99df69fe51d4fe7cfca4d47b04754f7b5a3 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:36:14 +0200 Subject: [PATCH 13/28] perf: one engine call per transition for TS machines 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 --- crates/core/src/config.rs | 16 +++ crates/core/src/lib.rs | 4 +- crates/core/src/machine.rs | 230 +++++++++++++++++++++++++++------ crates/wasm/src/bridge.rs | 12 +- crates/wasm/src/lib.rs | 18 ++- crates/wasm/src/runtime.rs | 141 +++++++++++++------- packages/core/src/compile.ts | 43 ++++-- packages/core/src/from-wasm.ts | 1 + packages/core/src/machine.ts | 144 +++++++++++++++++---- 9 files changed, 475 insertions(+), 134 deletions(-) diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 08b17b2..6a5f529 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -181,6 +181,10 @@ pub struct Transition { pub(crate) target: Option, pub(crate) guard: Option>, pub(crate) actions: Vec>, + /// The binding's id for this transition: when it leaves the state, the host runs it + /// whole (`Host::transition`). + #[cfg(feature = "host")] + pub(crate) host_id: Option, } /// Builds one transition candidate inside `StateBuilder::on` / `after`. @@ -229,6 +233,16 @@ impl TransitionBuilder { self.0.actions.push(action); self } + /// The binding's id for this transition. When it leaves the state, the host runs it + /// whole, in one call: effect cleanups, exit actions, the transition's actions, the + /// switch and its notification, entry actions and effects. Only for a host whose code + /// cannot read the engine's state mid-transition (e.g. a machine without derived + /// values), and a target without host delays: the engine switches after the call. + #[cfg(feature = "host")] + pub fn host_id(mut self, id: u32) -> Self { + self.0.host_id = Some(id); + self + } } /// A handler for one event (or one `after` delay): candidates in order, first passing guard wins. @@ -382,6 +396,8 @@ fn empty_transition() -> Transition { target: None, guard: None, actions: Vec::new(), + #[cfg(feature = "host")] + host_id: None, } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1a66299..cea694a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -39,9 +39,9 @@ pub use config::{ TransitionBuilder, }; pub use connector::{ConnectSnapshot, Connector, Reaction}; -#[cfg(feature = "host")] -pub use machine::Host; pub use machine::{Changes, Machine, Sender}; +#[cfg(feature = "host")] +pub use machine::{Host, HostTransition}; pub use params::{ActionParams, GuardParams, View}; pub use selection::Selection; pub use store::Store; diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs index 818d08e..d7a8799 100644 --- a/crates/core/src/machine.rs +++ b/crates/core/src/machine.rs @@ -40,8 +40,13 @@ const MAX_FLUSH: u32 = 10_000; pub trait Host { fn guard(&self, id: u32, event: Option<&T::Event>) -> bool; fn action(&self, id: u32, event: Option<&T::Event>); - /// Start an effect; return its cleanup, if any. - fn effect(&self, id: u32, event: Option<&T::Event>) -> Option; + /// Start effect `id`. The host keeps its cleanup, if any, for [`Host::stop_effects`]. + fn effect(&self, id: u32, event: Option<&T::Event>); + /// The effects the host started stop: run their cleanups, in start order, all of them + /// even if one fails. A host may hold this back to deliver it with its next callback, + /// first, and no later than [`Host::settle`] — which the engine calls before it + /// switches state. + fn stop_effects(&self); fn delay(&self, id: u32, event: Option<&T::Event>) -> u32; /// Evaluate computed value `id`, reporting what it read through `params`. fn computed(&self, id: u32, params: &ComputedParams<'_, T>) -> Rc; @@ -57,6 +62,33 @@ pub trait Host { fn notify(&self, state: T::State, changes: Changes); /// A transition finished: deliver any notification held back. fn settle(&self); + /// Run transition `id` whole (a transition built with a host id that leaves its + /// state): if `stop`, stop the effects; run `from`'s exit actions and the + /// transition's actions; announce the switch to `to`; run `to`'s entry actions; and if + /// `start`, start `to`'s effects (keeping their cleanups). + fn transition( + &self, + id: u32, + from: T::State, + to: T::State, + event: Option<&T::Event>, + stop: bool, + start: bool, + ) -> HostTransition; +} + +/// How a transition the host ran whole ended ([`Host::transition`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(feature = "host"), allow(dead_code))] +pub enum HostTransition { + /// Every step ran. + Done, + /// Failed before the switch: the machine stays where it was. + FailedBefore, + /// Failed in the switch's notification or an entry action: no effect started. + FailedAfter, + /// Failed while starting the effects: some may have started. + FailedInEffects, } pub(crate) enum Item { @@ -87,6 +119,8 @@ pub(crate) struct Core { /// Bumped on every state entry; a timer scheduled under another generation is stale. entry_counter: u64, state_cleanups: Vec, + /// The host started effects in the current state (it keeps their cleanups). + host_effects: bool, timers: Vec>, next_timer_id: u32, commands: Vec, @@ -141,12 +175,17 @@ impl Core { } fn set_state(&mut self, next: T::State) { + self.switch_state(next); + self.notify_changes.state = true; + self.pending_notifies += 1; + } + + /// Switch to `next` without owing a notification (the host announced it). + fn switch_state(&mut self, next: T::State) { self.state = next; self.tick += 1; self.state_changed_at = self.tick; self.changed_state = true; - self.notify_changes.state = true; - self.pending_notifies += 1; } } @@ -232,6 +271,7 @@ impl Machine { running: false, entry_counter: 0, state_cleanups: Vec::new(), + host_effects: false, timers: Vec::new(), next_timer_id: 1, commands: Vec::new(), @@ -445,6 +485,9 @@ impl Machine { // Watchers need no teardown: they detect only while running, and a run // still queued is dropped when it comes up. inner.stop_effects(); + if let Some(host) = inner.host() { + host.settle(); + } true }); if stopped && !inner.aborted() { @@ -632,6 +675,12 @@ impl Inner { entry: &'c Entry, event: Option<&T::Event>, ) -> Option<&'c Transition> { + // An unguarded first candidate always wins: no guard to set up. + if let Some(first) = entry.first() + && first.guard.is_none() + { + return Some(first); + } // The host evaluates the whole list in one call, with no borrow held. #[cfg(feature = "host")] if let Some(list) = entry.pick @@ -665,10 +714,18 @@ impl Inner { fn apply(&self, t: &Transition, event: Option<&T::Event>) { let cur = self.core.borrow().state; let next = t.target.unwrap_or(cur); - self.apply_steps(t, event, cur, next); - if next != cur + let leaving = next != cur; + // A leaving transition with a host id runs whole on the host, in one call. + #[cfg(feature = "host")] + if leaving + && let Some(id) = t.host_id && let Some(host) = self.host() { + self.host_transition(host, id, cur, next, event); + return; + } + self.apply_steps(t, event, cur, next); + if leaving && let Some(host) = self.host() { host.settle(); } } @@ -680,8 +737,8 @@ impl Inner { cur: T::State, next: T::State, ) { - let running = self.core.borrow().running; let leaving = next != cur; + let running = self.core.borrow().running; let states = &self.config.0.states; if leaving { if running { @@ -700,6 +757,13 @@ impl Inner { return; } if leaving { + // What the host holds back (an effects stop) lands before the switch. + if let Some(host) = self.host() { + host.settle(); + if self.aborted() { + return; + } + } self.core.borrow_mut().set_state(next); self.detect_watched(&self.core.borrow(), 0); self.drain_notifies(); @@ -713,6 +777,55 @@ impl Inner { } } + /// A leaving transition the host runs whole, in one call. + #[cfg(feature = "host")] + fn host_transition( + &self, + host: &Rc>, + id: u32, + cur: T::State, + next: T::State, + event: Option<&T::Event>, + ) { + let running = self.core.borrow().running; + let stop = running && self.stop_own_effects(); + if self.aborted() { + return; + } + let outcome = host.transition(id, cur, next, event, stop, running); + self.absorb_external(); + if outcome == HostTransition::FailedBefore { + return; + } + let node = &self.config.0.states[next.index()]; + let started = matches!( + outcome, + HostTransition::Done | HostTransition::FailedInEffects + ); + let schedule = { + let mut core = self.core.borrow_mut(); + core.switch_state(next); + let schedule = started && core.running; + if schedule { + core.host_effects = !node.effects.is_empty(); + if node.after.is_empty() { + core.entry_counter += 1; // a new entry, with no timer to stamp + } + } + schedule + }; + if !self.config.0.watch.is_empty() { + self.detect_watched(&self.core.borrow(), 0); + } + // The host announced the switch; the engine's own subscribers still hear it. + if let Some(bus) = self.bus.get() { + bus.notify(); + } + if schedule && !node.after.is_empty() { + self.schedule_after(next, event); + } + } + pub(crate) fn run_actions(&self, actions: &[Action], event: Option<&T::Event>) { for action in actions { self.run_action(action, event); @@ -822,6 +935,47 @@ impl Inner { } fn start_effects(&self, state: T::State, event: Option<&T::Event>) { + if !self.schedule_after(state, event) { + return; + } + let node = &self.config.0.states[state.index()]; + for effect in &node.effects { + let cleanup = match effect { + Effect::Fn(f) => self.call_effect(f, event), + Effect::Named(n) => match &n.imp { + Some(f) => self.call_effect(f, event), + None => { + missing("effect", n.name); + continue; + } + }, + // No borrow held: the host may read computed values, mark changes, send. + #[cfg(feature = "host")] + Effect::External(id) => { + match self.host() { + Some(host) => { + self.core.borrow_mut().host_effects = true; + host.effect(*id, event); + } + None => missing("effect host for", &id.to_string()), + } + None + } + }; + if let Some(cleanup) = cleanup { + self.core.borrow_mut().state_cleanups.push(cleanup); + } + self.absorb_external(); + self.drain_notifies(); + if self.aborted() { + return; + } + } + } + + /// Enter `state`'s timed transitions: a new entry generation, one timer per `after`. + /// Returns false when a host delay failed. + fn schedule_after(&self, state: T::State, event: Option<&T::Event>) -> bool { let node = &self.config.0.states[state.index()]; let generation = { let mut core = self.core.borrow_mut(); @@ -852,7 +1006,7 @@ impl Inner { }, }; if self.aborted() { - return; + return false; } let mut core = self.core.borrow_mut(); let id = core.next_timer_id; @@ -866,35 +1020,7 @@ impl Inner { }); core.commands.push(Command::StartTimer { id, ms }); } - for effect in &node.effects { - let cleanup = match effect { - Effect::Fn(f) => self.call_effect(f, event), - Effect::Named(n) => match &n.imp { - Some(f) => self.call_effect(f, event), - None => { - missing("effect", n.name); - continue; - } - }, - // No borrow held: the host may read computed values, mark changes, send. - #[cfg(feature = "host")] - Effect::External(id) => match self.host() { - Some(host) => host.effect(*id, event), - None => { - missing("effect host for", &id.to_string()); - None - } - }, - }; - if let Some(cleanup) = cleanup { - self.core.borrow_mut().state_cleanups.push(cleanup); - } - self.absorb_external(); - self.drain_notifies(); - if self.aborted() { - return; - } - } + true } fn call_effect( @@ -915,27 +1041,47 @@ impl Inner { /// leak the others: finish the pass, then report the first failure (a Rust panic is /// resumed; a host failure is left for the binding to rethrow). fn stop_effects(&self) { - let cleanups = { + if self.stop_own_effects() + && let Some(host) = self.host() + { + host.stop_effects(); + } + } + + /// Cancel the state's timers and run the engine's own cleanups. Returns whether the + /// host has effects to stop too. + fn stop_own_effects(&self) -> bool { + let (mut cleanups, host_effects) = { let mut core = self.core.borrow_mut(); let core = &mut *core; - if core.timers.is_empty() && core.state_cleanups.is_empty() { - return; + if core.timers.is_empty() && core.state_cleanups.is_empty() && !core.host_effects { + return false; } for t in core.timers.drain(..) { core.commands.push(Command::CancelTimer { id: t.id }); } - std::mem::take(&mut core.state_cleanups) + ( + std::mem::take(&mut core.state_cleanups), + std::mem::replace(&mut core.host_effects, false), + ) }; let mut first_panic = None; - for cleanup in cleanups { + for cleanup in cleanups.drain(..) { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cleanup)); if let Err(payload) = result { first_panic.get_or_insert(payload); } } + // Give the emptied list back, so the next state reuses its buffer. + if let Ok(mut core) = self.core.try_borrow_mut() + && core.state_cleanups.is_empty() + { + core.state_cleanups = cleanups; + } if let Some(payload) = first_panic { std::panic::resume_unwind(payload); } + host_effects } /// Seed each computed watch with its value's change stamp. A field watch needs no diff --git a/crates/wasm/src/bridge.rs b/crates/wasm/src/bridge.rs index 79997bd..dec28a1 100644 --- a/crates/wasm/src/bridge.rs +++ b/crates/wasm/src/bridge.rs @@ -142,13 +142,13 @@ impl Bridge { self.run_timers(link); *link.facade.borrow_mut() = JsValue::UNDEFINED; // The flag is the machine's: the engine raises it for its own failure too. - let halted = link.failed.replace(false); - if let Some(message) = self.machine.take_failure() { - return self.fail(message); - } - if halted { - return FAILED; + if link.failed.replace(false) { + return match self.machine.take_failure() { + Some(message) => self.fail(message), + None => FAILED, + }; } + return OK; } match self.machine.take_failure() { Some(message) => self.fail(message), diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index f573829..531b571 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -17,8 +17,8 @@ use std::cell::RefCell; use std::rc::Rc; use dunky_core::{ - Changes, Cleanup, ComputedKey, ComputedParams, Context, DeserializeEvent, EventEnum, Host, - Machine, SerializeFields, StateEnum, Types, + Changes, ComputedKey, ComputedParams, Context, DeserializeEvent, EventEnum, Host, Machine, + SerializeFields, StateEnum, Types, }; use serde::Serialize; use serde::de::DeserializeOwned; @@ -88,9 +88,10 @@ impl Host for Observer { fn action(&self, _: u32, _: Option<&T::Event>) { unreachable!("a Rust machine has no external actions") } - fn effect(&self, _: u32, _: Option<&T::Event>) -> Option { + fn effect(&self, _: u32, _: Option<&T::Event>) { unreachable!("a Rust machine has no external effects") } + fn stop_effects(&self) {} fn delay(&self, _: u32, _: Option<&T::Event>) -> u32 { unreachable!("a Rust machine has no external delays") } @@ -107,6 +108,17 @@ impl Host for Observer { self.link.notify(state.index() as u32, changes); } fn settle(&self) {} + fn transition( + &self, + _: u32, + _: T::State, + _: T::State, + _: Option<&T::Event>, + _: bool, + _: bool, + ) -> dunky_core::HostTransition { + unreachable!("a Rust machine has no host transitions") + } } /// The implementation behind every class [`export_machine!`] generates. diff --git a/crates/wasm/src/runtime.rs b/crates/wasm/src/runtime.rs index 530dcfd..9c235fb 100644 --- a/crates/wasm/src/runtime.rs +++ b/crates/wasm/src/runtime.rs @@ -14,8 +14,8 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use dunky_core::{ - Action, Branch, Changes, Cleanup, ComputedParams, Config, Context, Delay, Effect, EventEnum, - Field, Guard, Host, Machine, StateEnum, TransitionBuilder, Types, + Action, Branch, Changes, ComputedParams, Config, Context, Delay, Effect, EventEnum, Field, + Guard, Host, HostTransition, Machine, StateEnum, TransitionBuilder, Types, }; use serde::Deserialize; use wasm_bindgen::prelude::*; @@ -148,6 +148,9 @@ struct AfterSpec { #[derive(Deserialize)] struct CandidateSpec { + /// When set, the host runs the transition whole when it leaves the state. + #[serde(default)] + id: Option, #[serde(default)] target: Option, #[serde(default)] @@ -212,6 +215,9 @@ fn action(spec: &ActionSpec) -> Action { } fn candidate(mut t: TransitionBuilder, spec: &CandidateSpec) -> TransitionBuilder { + if let Some(id) = spec.id { + t = t.host_id(id); + } if let Some(target) = spec.target { t = t.target(DynState(target)); } @@ -312,15 +318,16 @@ extern "C" { /// 0 false, 1 true, 2 failed. #[wasm_bindgen(method)] fn guard(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> u32; - /// 0, or 1 when it failed. `notify` is a state change to deliver first, or -1. + /// 0, or 1 when it failed. `pre` is what to deliver first (see `Pre`), or 0. #[wasm_bindgen(method)] - fn action(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, notify: i32) -> u32; - /// The cleanup's id, 0 for no cleanup, or -1 when it failed. `notify` as for `action`. + fn action(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, pre: u32) -> u32; + /// Start an effect; the host keeps its cleanup. 0, or 1 when it failed. `pre` as for + /// `action`. #[wasm_bindgen(method)] - fn effect(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, notify: i32) -> i32; - /// 0, or 1 when it failed. + fn effect(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, pre: u32) -> u32; + /// Deliver `pre` on its own. 0, or 1 when it failed. #[wasm_bindgen(method)] - fn cleanup(this: &HostObject, facade: &JsValue, id: u32) -> u32; + fn pre(this: &HostObject, facade: &JsValue, pre: u32) -> u32; /// The delay in ms, or -1 when it failed. #[wasm_bindgen(method)] fn delay(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> f64; @@ -331,11 +338,24 @@ extern "C" { /// -2 when a guard failed. #[wasm_bindgen(method)] fn pick(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue) -> i32; - /// Run action list `list` in order. 0, or 1 when an action failed. `notify` as for + /// Run action list `list` in order. 0, or 1 when an action failed. `pre` as for /// `action`. #[wasm_bindgen(method)] - fn actions(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue, notify: i32) - -> u32; + fn actions(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue, pre: u32) -> u32; + /// Run transition `id` whole, from `from` to `to` (`flags`: 1 stops the effects, 2 + /// starts the target's). 0 done, 1 failed before the switch, 2 failed after it, 3 + /// failed while starting the effects. `pre` as for `action`. + #[wasm_bindgen(method)] + fn transition( + this: &HostObject, + facade: &JsValue, + id: u32, + from: u32, + to: u32, + event: &JsValue, + flags: u32, + pre: u32, + ) -> u32; } /// What one computed evaluation returned and read, reported by JS before it returns. @@ -349,22 +369,25 @@ struct Report { struct JsHost { link: Rc, report: RefCell>, - /// A state change held back to ride on the next action or effect call (one crossing - /// instead of two); -1 for none. Delivered first, or at `settle`. - pending: Cell, + /// What rides on the next action or effect call, delivered first (one crossing + /// instead of two), or at `settle`: bit 0 stops the effects, the bits above hold a + /// state change (the state + 1; 0 for none). + pre: Cell, } +const STOP_EFFECTS: u32 = 1; + impl JsHost { - /// The held-back state change, for a call that delivers it first. - fn take_pending(&self) -> i32 { - self.pending.replace(-1) + /// What is held back, for a call that delivers it first. + fn take_pre(&self) -> u32 { + self.pre.replace(0) } - /// Deliver the held-back state change now. + /// Deliver what is held back, now. fn deliver(&self) { - let state = self.take_pending(); - if state >= 0 { - self.link.notify(state as u32, Changes::default()); + let pre = self.take_pre(); + if pre != 0 && self.link.host.pre(&self.link.facade(), pre) != OK { + self.link.fail(); } } } @@ -391,32 +414,31 @@ impl Host for JsHost { } fn action(&self, id: u32, event: Option<&JsEvent>) { - let notify = self.take_pending(); + let pre = self.take_pre(); if with_event(event, |e| { - self.link.host.action(&self.link.facade(), id, e, notify) + self.link.host.action(&self.link.facade(), id, e, pre) }) != OK { self.link.fail(); } } - fn effect(&self, id: u32, event: Option<&JsEvent>) -> Option { - let notify = self.take_pending(); - let cleanup = with_event(event, |e| { - self.link.host.effect(&self.link.facade(), id, e, notify) - }); - if cleanup < 0 { + fn effect(&self, id: u32, event: Option<&JsEvent>) { + let pre = self.take_pre(); + if with_event(event, |e| { + self.link.host.effect(&self.link.facade(), id, e, pre) + }) != OK + { self.link.fail(); } - if cleanup <= 0 { - return None; + } + + fn stop_effects(&self) { + // A state change held back belongs before this stop. + if self.pre.get() > STOP_EFFECTS { + self.deliver(); } - let link = self.link.clone(); - Some(Box::new(move || { - if link.host.cleanup(&link.facade(), cleanup as u32) != OK { - link.fail(); - } - })) + self.pre.set(self.pre.get() | STOP_EFFECTS); } fn delay(&self, id: u32, event: Option<&JsEvent>) -> u32 { @@ -459,9 +481,9 @@ impl Host for JsHost { } fn actions(&self, list: u32, event: Option<&JsEvent>) { - let notify = self.take_pending(); + let pre = self.take_pre(); if with_event(event, |e| { - self.link.host.actions(&self.link.facade(), list, e, notify) + self.link.host.actions(&self.link.facade(), list, e, pre) }) != OK { self.link.fail(); @@ -470,17 +492,48 @@ impl Host for JsHost { fn notify(&self, state: DynState, changes: Changes) { // Context writes are JS's to announce; a state change waits for the next call. - self.deliver(); - if changes.fields == 0 { - self.pending.set(state.0 as i32); - } else { + if changes.fields != 0 { + self.deliver(); self.link.notify(state.0, changes); + return; } + if self.pre.get() > STOP_EFFECTS { + self.deliver(); + } + self.pre.set(self.pre.get() | ((state.0 + 1) << 1)); } fn settle(&self) { self.deliver(); } + + fn transition( + &self, + id: u32, + from: DynState, + to: DynState, + event: Option<&JsEvent>, + stop: bool, + start: bool, + ) -> HostTransition { + let pre = self.take_pre(); + let flags = u32::from(stop) | (u32::from(start) << 1); + let outcome = with_event(event, |e| { + let facade = self.link.facade(); + self.link + .host + .transition(&facade, id, from.0, to.0, e, flags, pre) + }); + if outcome != OK { + self.link.fail(); + } + match outcome { + 0 => HostTransition::Done, + 1 => HostTransition::FailedBefore, + 2 => HostTransition::FailedAfter, + _ => HostTransition::FailedInEffects, + } + } } // --------------------------------------------------------------------------------- @@ -526,7 +579,7 @@ impl JsMachine { let host = Rc::new(JsHost { link, report: RefCell::new(None), - pending: Cell::new(-1), + pre: Cell::new(0), }); bridge.machine().set_host(host.clone()); JsMachine { bridge, host } diff --git a/packages/core/src/compile.ts b/packages/core/src/compile.ts index 545e4e9..ab74a05 100644 --- a/packages/core/src/compile.ts +++ b/packages/core/src/compile.ts @@ -25,13 +25,16 @@ type AnyActions = Actions type AnyEntry = TransitionEntry /* eslint-enable @typescript-eslint/no-explicit-any */ -interface ActionSpec { +export interface ActionSpec { id?: number /** A run of actions the host runs in one call: an index into `actionLists`. */ list?: number oneOf?: Array<{ guard?: number; actions: ActionSpec[] }> } interface CandidateSpec { + /** The host runs the transition whole when it leaves the state: an index into + * `transitionActions`. */ + id?: number target?: number guard?: number actions: ActionSpec[] @@ -61,6 +64,12 @@ export interface Compiled { guardLists: number[][] /** Per action list: the actions, in order. */ actionLists: number[][] + /** What a transition run whole needs: per transition id, its actions; per state, its + * exit and entry actions and its effects. */ + transitionActions: ActionSpec[][] + exitActions: ActionSpec[][] + entryActions: ActionSpec[][] + stateEffects: number[][] actions: Fn[] effects: Fn[] delays: Fn[] @@ -169,16 +178,26 @@ export function compile(config: AnyConfig): Compiled { return specs } // One host call per guard walk, when two or more candidates have a guard. + // A leaving transition runs whole in one host call when nothing JS runs can read the + // engine's state mid-transition (no computed values) and the target has no named delay. + const runsWhole = Object.keys(config.computed ?? {}).length === 0 + const namedDelay = stateNames.map(name => + Object.keys(config.states[name]!.after ?? {}).some(key => Number.isNaN(Number(key))), + ) + const transitionActions: ActionSpec[][] = [] const entrySpec = (entry: AnyEntry): { candidates: CandidateSpec[]; pick?: number } => { - const candidates: CandidateSpec[] = (Array.isArray(entry) ? entry : [entry]).map(el => - typeof el === 'function' - ? { actions: actionList(el) } - : { - target: el.target === undefined ? undefined : indexOf(el.target), - guard: el.guard === undefined ? undefined : guardRef(el.guard), - actions: actionList(el.actions), - }, - ) + const candidates: CandidateSpec[] = (Array.isArray(entry) ? entry : [entry]).map(el => { + if (typeof el === 'function') return { actions: actionList(el) } + const target = el.target === undefined ? undefined : indexOf(el.target) + const actions = actionList(el.actions) + const whole = runsWhole && target !== undefined && !namedDelay[target] + return { + id: whole ? transitionActions.push(actions) - 1 : undefined, + target, + guard: el.guard === undefined ? undefined : guardRef(el.guard), + actions, + } + }) const guarded = candidates.filter(c => c.guard !== undefined).length if (guarded < 2) return { candidates } return { candidates, pick: guardLists.push(candidates.map(c => c.guard ?? -1)) - 1 } @@ -247,6 +266,10 @@ export function compile(config: AnyConfig): Compiled { guards, guardLists, actionLists, + transitionActions, + exitActions: states.map(state => state.exit), + entryActions: states.map(state => state.entry), + stateEffects: states.map(state => state.effects), actions, effects, delays, diff --git a/packages/core/src/from-wasm.ts b/packages/core/src/from-wasm.ts index f9827e1..1cc8203 100644 --- a/packages/core/src/from-wasm.ts +++ b/packages/core/src/from-wasm.ts @@ -83,6 +83,7 @@ class RustMachine< super(shape, ctx as Context, handle.state()) this.fields = shape.fields this.handle = handle + this.isRunning = handle.running() for (const [name, own] of shape.computedIndex) { const mapping = mappings[name] const map = typeof mapping === 'function' ? mapping : mapping?.map diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index ba100b2..432fa77 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,5 +1,5 @@ import { makeBroadcast, type Broadcast } from './broadcast' -import { compile, fieldBit, type Compiled } from './compile' +import { compile, fieldBit, type ActionSpec, type Compiled } from './compile' import { MACHINE_INIT } from './constants' import { makeGuardParams } from './guards' import { makeSelection } from './selection' @@ -79,6 +79,8 @@ export class MachineClass< epoch = 0 /** Nesting of engine calls: JS code calls back in from inside one. */ depth = 0 + /** Started and not stopped (mirrors the engine, so JS needs no call to ask). */ + isRunning = false constructor(shape: Shape, context: Context, current: number) { this.shape = shape @@ -138,7 +140,8 @@ export class MachineClass< } start = (): void => { - if (this.handle.running()) return + if (this.isRunning) return + this.isRunning = true this.depth++ const status = this.handle.start(this) this.depth-- @@ -146,7 +149,8 @@ export class MachineClass< if (this.startListeners) for (const fn of this.startListeners) fn() } stop = (): void => { - if (!this.handle.running()) return + if (!this.isRunning) return + this.isRunning = false this.depth++ const status = this.handle.stop(this) this.depth-- @@ -155,7 +159,7 @@ export class MachineClass< } onStart = (fn: () => void): (() => void) => { ;(this.startListeners ??= new Set()).add(fn) - if (this.handle.running()) fn() // already running — fire immediately so late registrants don't miss it + if (this.isRunning) fn() // already running — fire immediately so late registrants don't miss it return () => this.startListeners?.delete(fn) } onStop = (fn: () => void): (() => void) => { @@ -225,8 +229,8 @@ class TsMachine< > extends MachineClass { c: Compiled declare handle: JsMachine - cleanups: Map void> | null = null - nextCleanup = 1 + /** The cleanups of the effects started in the current state, in start order. */ + effectCleanups: Array<() => void> = [] /** Per computed value: the epoch it was last read at, and the value. */ readAt: number[] readValue: unknown[] @@ -298,6 +302,58 @@ class TsMachine< return value } + /** Run compiled action specs in order: callbacks, runs of callbacks, `oneOf`s. */ + runSpecs( + specs: ActionSpec[], + params: ActionParams, + event: unknown, + ): void { + const { actions } = this.c + for (let i = 0; i < specs.length; i++) { + const spec = specs[i]! + if (spec.id !== undefined) actions[spec.id]!(params) + else if (spec.list !== undefined) { + const list = this.c.actionLists[spec.list]! + for (let j = 0; j < list.length; j++) actions[list[j]!]!(params) + } else if (spec.oneOf) { + const guards = this.guardParams(event) + for (const branch of spec.oneOf) { + if (branch.guard === undefined || this.c.guards[branch.guard]!(guards)) { + this.runSpecs(branch.actions, params, event) + break + } + } + } + } + } + + /** What the engine held back to ride on this call: bit 0 stops the effects, the bits + * above hold a state change (the state + 1). */ + prelude(pre: number): void { + if (pre & 1) this.stopEffects() + const state = (pre >>> 1) - 1 + if (state >= 0) this.enter(state) + } + /** Run every cleanup, in start order, even past one that throws; then throw the first. */ + stopEffects(): void { + const cleanups = this.effectCleanups + if (cleanups.length === 0) return + this.effectCleanups = [] + let failed = false + let first: unknown + for (let i = 0; i < cleanups.length; i++) { + try { + cleanups[i]!() + } catch (error) { + if (!failed) { + failed = true + first = error + } + } + } + if (failed) throw first + } + actionParams(event: unknown): ActionParams { const e = event ?? INIT_EVENT if (paramsOf !== this || paramsEvent !== e) reuse(this, e) @@ -391,17 +447,29 @@ type Timeout = ReturnType interface Host { /** 0 false, 1 true, 2 failed. */ guard: (m: TsFacade, fn: number, event: unknown) => number - /** `notify`: a state change to announce first (it rides on this call), or -1. */ - action: (m: TsFacade, fn: number, event: unknown, notify: number) => number - /** The cleanup's id, 0 for no cleanup, -1 failed. */ - effect: (m: TsFacade, fn: number, event: unknown, notify: number) => number - cleanup: (m: TsFacade, id: number) => number + /** `pre`: what rides on this call, delivered first (see `prelude`), or 0. */ + action: (m: TsFacade, fn: number, event: unknown, pre: number) => number + /** Start an effect; its cleanup waits in `effectCleanups`. */ + effect: (m: TsFacade, fn: number, event: unknown, pre: number) => number + /** Deliver `pre` on its own. */ + pre: (m: TsFacade, pre: number) => number /** The delay in ms, -1 failed. */ delay: (m: TsFacade, fn: number, event: unknown) => number computed: (m: TsFacade, id: number) => number /** The first candidate of guard list `list` that passes, -1 for none, -2 failed. */ pick: (m: TsFacade, list: number, event: unknown) => number - actions: (m: TsFacade, list: number, event: unknown, notify: number) => number + actions: (m: TsFacade, list: number, event: unknown, pre: number) => number + /** Run transition `id` whole (`flags`: 1 stops the effects, 2 starts the target's). + * 0 done, 1 failed before the switch, 2 after it, 3 while starting the effects. */ + transition: ( + m: TsFacade, + id: number, + from: number, + to: number, + event: unknown, + flags: number, + pre: number, + ) => number notify: (m: Facade, state: number, lo: number, hi: number) => number startTimer: (m: Facade, id: number, ms: number) => Timeout cancelTimer: (timeout: Timeout) => void @@ -420,32 +488,28 @@ export const HOST: Host = { return 2 } }, - action: (m, fn, event, notify) => { + action: (m, fn, event, pre) => { try { - if (notify >= 0) m.enter(notify) + if (pre) m.prelude(pre) m.c.actions[fn]!(m.actionParams(event)) return OK } catch (error) { return m.fail(error) } }, - effect: (m, fn, event, notify) => { + effect: (m, fn, event, pre) => { try { - if (notify >= 0) m.enter(notify) + if (pre) m.prelude(pre) const cleanup = m.c.effects[fn]!(m.actionParams(event)) - if (typeof cleanup !== 'function') return 0 - ;(m.cleanups ??= new Map()).set(m.nextCleanup, cleanup) - return m.nextCleanup++ + if (typeof cleanup === 'function') m.effectCleanups.push(cleanup) + return OK } catch (error) { - m.fail(error) - return -1 + return m.fail(error) } }, - cleanup: (m, id) => { - const cleanup = m.cleanups!.get(id)! - m.cleanups!.delete(id) + pre: (m, pre) => { try { - cleanup() + m.prelude(pre) return OK } catch (error) { return m.fail(error) @@ -481,9 +545,9 @@ export const HOST: Host = { return -2 } }, - actions: (m, list, event, notify) => { + actions: (m, list, event, pre) => { try { - if (notify >= 0) m.enter(notify) + if (pre) m.prelude(pre) const actions = m.c.actionLists[list]! const params = m.actionParams(event) for (let i = 0; i < actions.length; i++) m.c.actions[actions[i]!]!(params) @@ -492,6 +556,32 @@ export const HOST: Host = { return m.fail(error) } }, + transition: (m, id, from, to, event, flags, pre) => { + let phase = 1 + try { + if (pre) m.prelude(pre) + if (flags & 1) m.stopEffects() + const params = m.actionParams(event) + m.runSpecs(m.c.exitActions[from]!, params, event) + m.runSpecs(m.c.transitionActions[id]!, params, event) + phase = 2 + m.enter(to) + m.runSpecs(m.c.entryActions[to]!, params, event) + // An action may have stopped the machine mid-transition. + if (flags & 2 && m.isRunning) { + phase = 3 + const effects = m.c.stateEffects[to]! + for (let i = 0; i < effects.length; i++) { + const cleanup = m.c.effects[effects[i]!]!(params) + if (typeof cleanup === 'function') m.effectCleanups.push(cleanup) + } + } + return OK + } catch (error) { + m.fail(error) + return phase + } + }, notify: (m, state, lo, hi) => { try { if (lo | hi) { From e78730603dba803ba2ab62f2e9ee4145dce09f49 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:36:16 +0200 Subject: [PATCH 14/28] docs: one vocabulary for the repo, the TS core and the Rust engine 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 --- ARCHITECTURE.md | 255 +++++++++++++++++++++++++++----------------- README.md | 13 ++- crates/core/SPEC.md | 24 ++++- crates/wasm/SPEC.md | 23 ++-- 4 files changed, 199 insertions(+), 116 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f89f1fb..719089d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,69 +4,71 @@ For _rules_ (do this, never do that), see [`AGENTS.md`](./AGENTS.md). ## The big picture -A component's behavior is a **state machine** — plain TypeScript that knows -nothing about where it renders. A thin per-target layer plugs that machine into a -runtime: same machine, same behavior, same accessibility intent, different render. +A component's behavior is a **state machine** that knows nothing about where it +renders. You write the machine in TypeScript or in Rust; both run on **one engine**, +written in Rust. A thin per-target layer plugs the machine into a runtime: same machine, +same behavior, same accessibility intent, different render. ``` -The host + machine written in TypeScript machine written in Rust + (a config) (dunky-core types) + | | + v v +----------------------------------------------------------------------+ -| packages/core | -| No runtime render — TS over the Rust engine (wasm), runs anywhere | -| states, events, context, guards, actions, effects, select | +| the engine: crates/core (dunky-core), Rust | +| runs the graph: queue, transitions, effects, timers, watchers, | +| computed bookkeeping | +----------------------------------------------------------------------+ - | consumed by every target - v -+-----------------------------------------------------------------------+ -| shared/bindings | -| Substrate-agnostic event + attr vocabulary (onPress, role, …) | -+-----------------------------------------------------------------------+ - | -+-----------------------------------------------------------------------+ -| shared/utils | -| Cross-target helpers (mergeProps, composeHandlers) | -+-----------------------------------------------------------------------+ - | bridged per target - v -+------------------------------------------------------------------------+ -| (react, solid, native, opentui, …) | -| Runtime-specific bridge | -| • lifecycle (build + start/stop) • normalize bindings -> props | -| • selector subscription | -+------------------------------------------------------------------------+ - | imported as a normal package - v - Consumer app + | engine bindings: crates/wasm (JS, as wasm), + | crates/uniffi (React Native, over JSI) + v ++----------------------------------------------------------------------+ +| packages/core (@dunky.dev/state-machine) | +| the TS API and the Machine facade: machine(), fromWasm(), | +| connector, compose, createStore | ++----------------------------------------------------------------------+ + | consumed by every target + v ++----------------------------------------------------------------------+ +| shared/bindings - substrate-agnostic event + attr vocabulary | +| shared/utils - cross-target helpers (mergeProps, ...) | ++----------------------------------------------------------------------+ + | bridged per target + v ++----------------------------------------------------------------------+ +| (react, solid, native, opentui, ...) | +| lifecycle (build + start/stop), normalize bindings -> props, | +| selector subscription | ++----------------------------------------------------------------------+ + | imported as a normal package + v + consumer app ``` This repo is the **engine** — the agnostic machine plus its per-target bridges. The components that consume it (and their style/codegen pipeline) live elsewhere. -> **Status: experimental, but it compiles and runs.** The engine is a Rust -> plain-mutation kernel compiled to wasm (no signals; see -> [The engine](#the-engine)). The suite is green and `tsc` is clean. It's an -> in-progress exploration — the API may still move. - -## Three layers +> **Status: experimental, but it compiles and runs.** The suite is green and `tsc` +> is clean. It's an in-progress exploration — the API may still move. -The repo splits in three layers. **`core/`** is the agnostic side — TS over the -Rust engine, no renderer. It says _what_ behavior is: states, transitions, context, guards, -actions. Nothing in `core/` knows that React or the DOM exists. +## Layers -**`shared/`** is the cross-target side — `shared/bindings` owns the -substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils` -owns cross-target helpers (mergeProps, composeHandlers). +| Layer | Where | Job | +| -------------- | ------------------------------------ | ----------------------------------------------------------------------- | +| Engine | `crates/core` | What every machine does: the behavior contract, in Rust | +| Engine binding | `crates/wasm`, `crates/uniffi` | Connects the engine to a runtime: JS through wasm, React Native via JSI | +| Core | `packages/core` | The TS API every consumer imports, and the `Machine` facade | +| Shared | `packages/shared/bindings`, `/utils` | The vocabulary and helpers every target shares | +| Target | `packages/` | One substrate: lifecycle bridge, props translator, selector | -**`/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any -future renderer. Each target is the runtime bridge for one environment: the -lifecycle bridge, the event normalization, and the selector subscription all -live here. +Nothing below a layer knows the layer above it. The engine knows no runtime; core knows +no renderer; a target knows one substrate and nothing about the engine. ## The core rule: the machine never sees props A machine is pure behavior — states, transitions, context, effects. It does **not** read the consumer's props. Props are where the environment leaks in (a -DOM event handed to `onOpenChange`, a platform timer, a host-specific callback); +DOM event handed to `onOpenChange`, a platform timer, a runtime-specific callback); if the machine read them, it would be coupled to the shape one runtime happens to give it. @@ -92,31 +94,38 @@ Zag, whose machines read props directly.) ## Project structure -| File / location | What it owns | +| Location | What it owns | | --------------------------- | ---------------------------------------------------------------------- | +| `crates/core/` | The engine (`dunky-core`): runtime, typed authoring, binding API | +| `crates/macros/` | The derives for Rust machines (`State`, `Event`, `Context`) | +| `crates/wasm/` | The JS engine binding (`dunky-wasm`): protocol, runtime, export macro | +| `crates/core-wasm/` | The wasm `packages/core` ships (built into `packages/core/wasm`) | +| `crates/uniffi/` | The React Native engine binding, over JSI (in progress) | | `packages/core/` | The TS API: `machine()`, `fromWasm()`, connector, the `Machine` facade | | `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | | `packages/shared/utils/` | mergeProps, composeHandlers | | `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | -| `crates/core/` | The engine (Rust; implements the core SPEC) | -| `crates/wasm/` | The JS protocol: runtime for TS machines, `export_machine!` for Rust | -| `crates/core-wasm/` | The wasm `packages/core` ships (built into `packages/core/wasm`) | -| `crates/uniffi/` | React Native over JSI (in progress) | +| `sandbox/` | One demo app per substrate; `sandbox/shared/rust`: machines in Rust | +| `benchmark/` | The perf suite; `benchmark/rust`: the Rust twins of its machines | + +Each package's definition is its `SPEC.md`; the README is the quick reference. ## The map ``` -core agnostic state-machine engine — no React, +packages/core the TS API over the engine — no React, | no DOM, no RN (one file per concern; | public surface in index) -+-- machine() builds a stopped service from a config; -| .start()/.stop()/.send()/.state/.select ++-- machine() compiles a config, builds a stopped TS +| machine: .start()/.stop()/.send()/.select ++-- fromWasm() wraps a Rust machine in the same facade +-- context / state one plain-object context per machine | (mutated in place) + flat states +-- guards / actions and/or/not combinators · oneOf +-- connector connect() -> live, subscribable snapshot +-- compose run several machines as one (orthogonal | regions): start/stop + sync + combine ++-- createStore a reactive cell shared between machines shared/bindings substrate-agnostic event + attr vocabulary +-- (onPress, role, aria-*, …) consumed by every target's normalize @@ -131,16 +140,6 @@ shared/utils cross-target, cross-component helpers +-- normalize bindings -> target props ``` -Three package groups, three jobs: - -- **`core/`** — _the agnostic side_. Behavior, types, and the engine that - knows nothing about a renderer. -- **`shared/`** — _the cross-target side_. `shared/bindings` owns the - event + attr vocabulary; `shared/utils` owns agnostic helpers (prop - merging, handler composition). -- **`/`** — _the substrate side_. One folder per renderer - (`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator. - ## The machine parts `machine.ts` is the state graph (states, transitions, action impls). It @@ -177,21 +176,24 @@ whether it needs props/platform or not: ## The engine -The engine is Rust: `crates/core` (`dunky-core`), compiled to WebAssembly. It runs the -graph — the run-to-completion queue, transition resolution, entry/exit order, the -effects lifecycle, `after` timers, watchers and computed bookkeeping — and implements +The engine is `crates/core` (`dunky-core`). It runs the graph — the run-to-completion +queue, transition resolution, entry/exit order, the effects lifecycle, `after` timers, +watchers and computed bookkeeping — and implements [`packages/core/SPEC.md`](packages/core/SPEC.md); its Rust tests are ported from -`packages/core/tests`. A machine is written in TypeScript or in Rust, and both run on it: +`packages/core/tests`. It owns no clock and no IO: a timer is a command its host runs. +What is specific to the crate is [`crates/core/SPEC.md`](crates/core/SPEC.md). + +A machine is written in TypeScript or in Rust; both run on the engine: ``` TS machine: machine(config) Rust machine: dunky-core types | | - | compiled to numbers + JS tables | export_machine! (your crate) + | compiled: numbers + JS tables | export_machine! (your crate) v v -crates/core-wasm (inside the package) your wasm module +crates/core-wasm (inlined in the package) your wasm module | | +------------------+-------------------+ - | one protocol (crates/wasm): events in by kind, + | the protocol (crates/wasm): calls with status codes, | a notify per change, timers on the host clock v packages/core: the Machine facade (machine() / fromWasm()) @@ -200,38 +202,91 @@ crates/core-wasm (inside the package) your wasm module the targets: react, solid, native, opentui ``` -- **TS machines.** `machine(config)` compiles the config once: states and event types - become numbers, the user code (guards, actions, effects, delays, computed) becomes - tables. Rust calls back into them by index; the context stays a plain JS object, - and each `setContext` reports its change mask so Rust drives watchers and computed - staleness. The wasm is inlined in the package and starts on the first `machine()`. -- **Rust machines.** Typed Rust (`#[derive(State, Event, Context)]`), exported to JS - with `dunky_wasm::export_machine!` and wrapped with `fromWasm`: the facade keeps a - JS mirror of the context and re-reads only the fields each notify names. -- **One facade.** Both kinds share the `Machine` facade and the host functions - (`notify`, `startTimer`, `cancelTimer`), which take numbers and return status codes. - JS passes the facade with each call; Rust holds no JS object between calls, so only JS - references keep a machine alive, and a pending timer holds its machine, like a JS - timer holds `this`. -- **Few crossings.** A TS machine announces its own context writes in JS; a run of - actions, a guard walk, and a state change with its entry actions each cost one call - into JS. -- **React Native.** Hermes has no WebAssembly; `crates/uniffi` is the start of a native - engine over JSI. - -The sandbox and the benchmark write machines in Rust too (`sandbox/shared/rust`, -`benchmark/rust`); `pnpm build:wasm` builds every module. +### A machine written in TypeScript + +- `machine(config)` **compiles** the config once (cached per config object): states and + event types become numbers; guards, actions, effects, delays and computed definitions + become JS tables. The engine gets the numbers; the user code stays in JS. +- The engine runs the graph and calls back into the user code by index, through the + **host functions**. The **context** stays a plain JS object, so a read is a property + read. +- Each callback crossing is batched: a run of actions is one call, a guard walk is one + call, and — when the machine has no computed values — a whole leaving transition + (cleanups, exit, actions, the switch and its notification, entry, effects) is one call. +- JS announces its own context writes to the subscribers. The engine hears about a write + only when watchers or computed values need it, and a computed value read again with + no change in between comes from a JS cache, with no call. +- The wasm (`crates/core-wasm`) is inlined in the package and starts synchronously on the + first `machine()` call. + +### A machine written in Rust + +- Typed Rust (`#[derive(State, Event, Context)]`, a `Config` built once), exported to JS + with `dunky_wasm::export_machine!` in your own wasm module, and wrapped with `fromWasm`. +- The engine runs everything, user code included, inside wasm. The facade keeps a JS + **mirror** of the context and re-reads only the fields each notify names; a computed + value crosses only when it changed. +- In JS a TS machine is faster (its context already lives in JS); a Rust machine is for + reuse — the same machine runs in Rust programs, and over JSI on React Native. + +### One facade, one protocol + +- Both kinds share the **facade** (`MachineClass` in `packages/core`): it maps names to + numbers, holds the context (or its mirror), and fans each change out to the + subscribers. Targets, the connector and `compose` see only the `Machine` interface. +- The **protocol** ([`crates/wasm/SPEC.md`](crates/wasm/SPEC.md)): JS passes the facade + with each call into the engine; the engine hands it to every host function of that + call and lets it go afterwards. Rust holds no JS object between calls, so only JS + references keep a machine alive — a pending timer holds its machine, like a JS timer + holds `this`. +- **Errors** stay in JS: a host function catches what user code throws, keeps it on the + facade, and returns a failure status; the engine raises its halt flag and stops the + step; the facade rethrows the error to the caller. + +### React Native + +Hermes has no WebAssembly. `crates/uniffi` is the engine binding for React Native over +JSI (in progress); until it lands, the package does not run on React Native. ## Vocabulary -| Term | What it is | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **host** | The agnostic core — `packages/core/*`. Declares what behavior is. | -| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `solid`, `native`, `opentui`, …). | -| **machine** | A state-graph config consumed by `machine()`; returns a startable service. | -| **connect** | A function returning the logical surface a view spreads onto elements. | -| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. | -| **compose** | Run several machines as one unit (orthogonal regions): bundled `start`/`stop` + `sync` + `combine`. | +### The repo + +| Term | What it is | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **engine** | `crates/core` (`dunky-core`): the Rust runtime every machine runs on. It owns no clock and no IO. | +| **engine binding** | A crate that connects the engine to a runtime: `crates/wasm` (JS, through wasm), `crates/uniffi` (React Native, over JSI). | +| **core** | `packages/core` (`@dunky.dev/state-machine`): the TS API every consumer imports, and the facade. | +| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `solid`, `native`, `opentui`, …). | +| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. | +| **connect** | A function returning the logical surface a view spreads onto elements. | +| **compose** | Run several machines as one unit (orthogonal regions): bundled `start`/`stop` + `sync` + `combine`. | + +### Machines (core, TS) + +| Term | What it is | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **config** | A machine written in TypeScript: the state graph and its user code, a plain object. `machine()` builds a service from it. | +| **compiled config** | A config compiled once for the engine: numbers for states and event types, JS tables for the user code, and the engine's own copy of the graph. | +| **TS machine** | A service built from a config. The engine runs the graph; its user code and its context stay in JS. | +| **Rust machine** | An instance of a class exported with `export_machine!`, wrapped with `fromWasm`. The engine runs it all; JS keeps a mirror of its context. | +| **facade** | The `Machine` object JS code holds, for both kinds: names to numbers, the context (or its mirror), the subscribers. Targets only ever see a facade. | +| **source** | What a target's lifecycle hook builds a service from: a config, or a ready facade. | +| **host functions** | The JS functions the engine calls back into (`HOST` in `packages/core`): user code by index, notify, timers. They take numbers and return status codes. | +| **status code** | What every call into the engine returns: 0 done, 1 user code failed (the facade holds the error), 3 the engine failed (`takeFailure`). | + +### The engine (Rust) + +| Term | What it is | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **typed machine** | A machine written in Rust: its states, events and context are Rust types, its config is built once with a builder and shared by every instance. | +| **host** | The `Host` trait (cargo feature `host`): what an engine binding implements to run a machine's external parts and hear its changes. In JS, the host is the set of host functions. | +| **external part** | A guard, action, effect, delay or computed definition referenced by number, run by the host — the user code of a TS machine. | +| **halt flag** | Shared by the engine and its host: raised when a callback fails, it stops the current step at the next checkpoint. The binding lowers it once it has reported the failure. | +| **notify** | One per effective change, in order, with the state and the changed fields. A host may hold one back to deliver it with its next callback, no later than when the transition ends. | +| **whole transition** | A leaving transition the host runs in one call: effect cleanups, exit actions, actions, the switch and its notification, entry actions, effects. Only where JS cannot see the switch. | +| **command** | What a timer is to the engine: start or cancel, with an id. The host runs it on its clock and reports a due timer. | +| **change mask** | A bit per context field (64 at most), plus the state: what a write or a step changed. | ## Versioning diff --git a/README.md b/README.md index 5503127..1e67786 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,12 @@ 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. +- **engine** — the state-machine runtime, in Rust (`crates/core`). Pure behavior: + states, transitions, context, effects. Knows nothing about a renderer, and runs + machines written in TypeScript or in Rust. +- **core** — the TS API over the engine (`@dunky.dev/state-machine`): `machine()` + for machines written in TS, `fromWasm()` for machines written in Rust, and the + `Machine` object every target consumes. - **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 @@ -70,8 +74,9 @@ 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. +- **[`ARCHITECTURE.md`](./ARCHITECTURE.md)** — the big-picture map, the layered model, and the vocabulary. +- **[`packages/core/README.md`](./packages/core/README.md)** — the TS API, its quick reference; [`SPEC.md`](./packages/core/SPEC.md) defines it. +- **[`crates/core/README.md`](./crates/core/README.md)** — the Rust engine and writing machines in Rust; [`SPEC.md`](./crates/core/SPEC.md) defines it. - **[`benchmark/README.md`](./benchmark/README.md)** — what's measured, the methodology, and results vs. XState & Zag. - **[`ACCESSIBILITY.md`](./ACCESSIBILITY.md)** — the external specs every package answers to. - **[`AGENTS.md`](./AGENTS.md)** — the contributor / agent contract. diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md index 15781e9..9c2e57a 100644 --- a/crates/core/SPEC.md +++ b/crates/core/SPEC.md @@ -114,13 +114,27 @@ A **host** (cargo feature `host`) connects a machine to another runtime. It has - **Run the external parts.** A config may reference guards, actions, effects, delays and derived definitions by number instead of Rust code — for example, the user code of a machine written in TypeScript. The engine calls the host for them while it holds no - mutable borrow, so the host may read derived values, write context, and dispatch. A - run of external actions can be one host call, and so can a guard walk over a handler's - candidates (the host returns the first that passes). + mutable borrow, so the host may read derived values, write context, and dispatch. - **Hear every change.** After each effective change the engine notifies the host: once per change, in order, with no borrow held, with the current state and what changed - since the previous notify. A host may hold a notification back and deliver it with its - next callback, first, and no later than when the transition ends. + since the previous notify. + +Fewer calls, same order: + +- A run of external actions is one host call, and so is a guard walk over a handler's + candidates (the host returns the first that passes). +- The host keeps the cleanups of the effects it starts. When the state's effects stop, + the engine tells it once, and the host runs them all, in start order, even past one + that fails. +- A host may hold a notification or an effects stop back and deliver it first with its + next callback. The engine settles the host — everything held back is delivered — before + it switches state, when a transition ends, and when the machine stops. +- A transition with a host id that leaves its state runs **whole** on the host, in one + call: the effects stop, exit actions, the transition's actions, the switch and its + notification, entry actions, the target's effects. The engine switches state after the + call, so a binding gives a host id only when nothing the host runs can read the + engine's state mid-transition (no derived values) and the target has no host delay. If + the call fails before the switch, the machine stays where it was. The contract: diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md index 92dfa03..3299f5a 100644 --- a/crates/wasm/SPEC.md +++ b/crates/wasm/SPEC.md @@ -69,13 +69,22 @@ machines; the conversion helpers between JS values and Rust values. - `startTimer(facade, id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels it. A due timer calls back into the machine with its id. - For TS machines only, by index in the JS tables, with the event that caused the call - (none for a boot or a data-reaction): `guard`, `action`, `actions` (a run of actions - in one call), `pick` (a guard walk in one call: the first candidate that passes), - `effect` (returns a cleanup id, or 0), `cleanup`, `delay` and `computed` (reports its - value and its reads back before it returns). -- A TS machine's state change rides on the next `action`, `actions` or `effect` call of - its transition and is announced first; without one, it is delivered on its own before - the transition ends. + (none for a boot or a data-reaction): + - `guard`; `pick`, a guard walk in one call (the first candidate that passes); + - `action`; `actions`, a run of actions in one call; + - `effect`: starts an effect, and the facade keeps its cleanup; + - `delay`; `computed`, which reports its value and its reads back before it returns; + - `transition`: a leaving transition run whole (see below). +- What the engine holds back rides on the next `action`, `actions`, `effect` or + `transition` call as `pre`, and runs first: bit 0 stops the effects (the facade runs + every cleanup, in start order, even past one that throws), the bits above announce a + state change (the state + 1). With no such call, `pre` is delivered on its own, before + the state switches and when the transition ends. +- A **whole transition** is one call for a TS machine without computed values, when the + target has no named delay: stop the effects, exit actions, the transition's actions, + announce the switch, entry actions, start the target's effects. It returns where it + failed: before the switch (the machine stays put), after it, or while starting the + effects. ### Calls From 8afdd73c551635dd7cc83b366662d4aa74b6dd28 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 11:41:01 +0200 Subject: [PATCH 15/28] perf(benchmark): numbers after the churn work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 6 +- benchmark/README.md | 108 ++++++++++++------------- packages/core/README.md | 2 +- website/src/content/docs/benchmark.mdx | 38 ++++----- 4 files changed, 77 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 1e67786..7f147d6 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,11 @@ engine is built for it: | At scale (thousands of machines) | Dunky | XState | Zag | | -------------------------------- | --------: | -----: | ------: | -| Event throughput (ops/s) | **9.1 M** | 1.6 M | n/a ᵃ | -| Memory / machine, 2-field (KB) | **3.5** | 3.6 | 8.9 | +| Event throughput (ops/s) | **9.7 M** | 1.6 M | n/a ᵃ | +| Memory / machine, 2-field (KB) | **3.6** | 3.6 | 8.9 | | Memory / machine, 64-field (KB) | **4.1** | 4.1 | **134** | -→ **~5.7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the +→ **~6× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the **[benchmark README](./benchmark/README.md)**. **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. diff --git a/benchmark/README.md b/benchmark/README.md index 18794c5..b3926ad 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -97,11 +97,11 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 653.3 K | 456.9 K | n/a ᵃ | -| 1000 | 56.1 K | 48.7 K | n/a ᵃ | -| 5000 | **14.6 K** | 1.4 K | n/a ᵃ | +| 100 | 653.3 K | 451.0 K | n/a ᵃ | +| 1000 | 56.5 K | 49.3 K | n/a ᵃ | +| 5000 | **14.7 K** | 1.4 K | n/a ᵃ | -→ Roughly par at small N, but Dunky **~10× faster at 5000 observers** — XState's +→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. **B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The @@ -111,10 +111,10 @@ model gets for free and a coarse bus has to work to ignore. | Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **5.1 M** | 902.9 K | n/a ᵃ | -| 5000 | **3.0 M** | 711.7 K | n/a ᵃ | +| 1000 | **5.1 M** | 1.0 M | n/a ᵃ | +| 5000 | **4.7 M** | 919.2 K | n/a ᵃ | -→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 +→ Dunky is **~5× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. **C. Throughput — single machine, one event.** Per-transition cost with no @@ -122,12 +122,12 @@ selection scaling — the raw `send` price. | Single machine, one event | ops/sec | | ------------------------- | --------: | -| Dunky | **9.1 M** | +| Dunky | **9.7 M** | | XState (raw) | 1.6 M | | XState (diffed) | 1.6 M | | Zag | n/a ᵃ | -→ Dunky pushes **~5.7× the events/sec** of XState. Context is mutated in place, +→ Dunky pushes **~6× the events/sec** of XState. Context is mutated in place, so a transition allocates nothing; XState builds a fresh snapshot per event. ## 2. Compose / synced machines (`tests/compose.ts`) @@ -144,12 +144,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 8.6 M | 9.1 M | n/a ᶠ | n/a ᶠ | -| 10 | 8.2 M | 8.4 M | n/a ᶠ | n/a ᶠ | -| 50 | 6.7 M | 7.5 M | n/a ᶠ | n/a ᶠ | +| 2 | 9.1 M | 9.2 M | n/a ᶠ | n/a ᶠ | +| 10 | 7.9 M | 8.4 M | n/a ᶠ | n/a ᶠ | +| 50 | 7.4 M | 7.7 M | n/a ᶠ | n/a ᶠ | -→ Cross-region coordination stays in the **~6.7–9.1 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~18–23% going from 2 to 50. +→ Cross-region coordination stays in the **~7.4–9.2 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~16–19% going from 2 to 50. > A third "chain" sub-test (a sync rule that `send()`s downstream every change) was > removed: under a tight loop it shows superlinear slowdown. That's a real @@ -165,13 +165,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z | Scenario | Dunky (ops/sec) | XState | Zag | | ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **55.6 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 5.4 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 3.3 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 960.5 K | n/a ᶠ | n/a ᵃ | +| Cached read (no change) | **56.3 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 5.7 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 3.4 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 982.4 K | n/a ᶠ | n/a ᵃ | -→ A cached read is **~55.6 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~5.4 M/sec — read-key tracking means +→ A cached read is **~56.3 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~5.7 M/sec — read-key tracking means you only pay the recompute when an input you actually read changes. ## 4. Engine hot paths (`tests/engine.ts`) @@ -183,18 +183,18 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 4.3 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.2 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 2 candidates | 4.4 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.3 M | n/a ᶠ | n/a ᵃ | | Guard fallthrough — 32 candidates | 3.9 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 6.2 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 4.4 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 9.5 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 6.9 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 7.5 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 6.7 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 10.0 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 7.1 M | n/a ᶠ | n/a ᵃ | -→ Even the heavy paths hold **~3.9–9.5 M ops/sec**: a 32-candidate guard walk +→ Even the heavy paths hold **~3.9–10.0 M ops/sec**: a 32-candidate guard walk (evaluated in one call from the engine into JS), full state transitions with entry/exit actions, and effect boot/cleanup every transition all stay within -~2.3× of a bare `send`. +~2.5× of a bare `send`. ## 5. Construction cost (`tests/construct.ts`) @@ -204,11 +204,11 @@ warmed first. | Build + start | Dunky (µs/machine) | XState | Zag | | ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.43 | 1.10 | 4.86 | +| 10 000 | 1.38 | 1.18 | 5.23 | → Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.3× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.4× faster than Zag's per-field reactive cells. +~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.8× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) @@ -220,8 +220,8 @@ the footprint a churny app actually pays). | Context | Dunky (KB/machine) | XState | Zag | | -------- | -----------------: | -----: | ------: | -| 2-field | 3.53 | 3.61 | 8.92 | -| 64-field | 4.05 | 4.10 | **134** | +| 2-field | 3.56 | 3.61 | 8.92 | +| 64-field | 4.08 | 4.10 | **134** | → Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with the data you store, not with a per-field cell. **Zag is the contrast**: one reactive @@ -234,8 +234,8 @@ while a lazy-copy scheme steps up once writes start: | 64-field, 5000 machines | Dunky | XState | Zag | | ----------------------- | ----: | -----: | --: | -| Idle (never written) | 4.02 | 3.55 | 130 | -| Written (1 event each) | 4.05 | 4.10 | 134 | +| Idle (never written) | 4.06 | 3.55 | 130 | +| Written (1 event each) | 4.08 | 4.10 | 134 | → Dunky idle ≈ written; XState's first `assign` allocates a per-actor context, so its written row grows. @@ -253,15 +253,15 @@ List of 1000 rows: | Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | | -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 3.8 | **2.4** | -| Dunky/selector | 2 | 4.7 | 3.8 | +| Dunky/instance | **2** | 3.9 | **2.5** | +| Dunky/selector | 2 | 4.8 | 3.8 | | xstate/selector | 2 | 3.8 | 4.5 | -| zag/instance | 2 | 4.3 | n/a ᵃ | -| naive (anti-pattern) | **980** | 5.2 | 37.4 | +| zag/instance | 2 | 4.2 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.8 | 36.5 | → Every properly-set-up engine wakes only the **2** rows that changed (vs. the naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~16× the wall time). Among the surgical strategies Dunky re-renders **~1.9× +~15× the wall time). Among the surgical strategies Dunky re-renders **~1.8× faster than XState**. Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the @@ -278,31 +278,31 @@ wasm handle with no facade: the floor. A call into wasm costs the same as a JS c | Scenario | TS machine (ops/sec) | Rust machine | Rust, raw | | ----------------------------------------- | -------------------: | -----------: | --------: | -| One event, observed field | **9.4 M** | 6.1 M | 12.8 M | -| One event, unobserved field | **9.4 M** | 5.8 M | 12.6 M | -| State churn — exit+entry every event | **7.2 M** | 6.1 M | 9.1 M | -| Guard fallthrough — 8 candidates | **7.8 M** | 5.7 M | — | -| Palette — type + read, 11 commands | **703.7 K** | 400.7 K | — | -| Palette — type + read, 1,000 commands | **13.1 K** | 8.1 K | — | -| Palette — type + read, 10,000 commands | **1.3 K** | 786 | — | -| Construct + start one machine (µs / mach) | **4.72** | 5.00 | 2.01 | - -→ In JavaScript, a machine written in TS handles **~1.2–1.6× the events** of its Rust +| One event, observed field | **10.5 M** | 6.4 M | 13.1 M | +| One event, unobserved field | **10.2 M** | 6.2 M | 13.0 M | +| State churn — exit+entry every event | **9.7 M** | 6.2 M | 8.9 M | +| Guard fallthrough — 8 candidates | **8.3 M** | 7.9 M | — | +| Palette — type + read, 11 commands | **778.3 K** | 427.0 K | — | +| Palette — type + read, 1,000 commands | **13.1 K** | 8.2 K | — | +| Palette — type + read, 10,000 commands | **1.3 K** | 817 | — | +| Construct + start one machine (µs / mach) | **4.86** | 4.93 | 2.08 | + +→ In JavaScript, a machine written in TS handles **~1.1–1.6× the events** of its Rust twin. Its context already lives in JS, while a Rust machine pushes each change back across the boundary: a notify, then a read per changed field to refresh the JS context mirror. The raw handle (no subscribers, no mirror) shows the engine itself: -~12.8 M events/sec. +~13 M events/sec. → Heavy derived data favors TS too: the palette filters **~1.6–1.8× faster** as a TS machine, because its filter runs as JIT-compiled JS and its results are already JS values, while the Rust machine serializes its results on each change. Crossing as indices mapped back onto your own objects (the rows above) beats crossing whole -objects by ~22% (6.6 K at 1,000 commands). +objects by ~22% (6.7 K at 1,000 commands). → Rust machines are for reuse: the same machine runs in a Rust program and over JSI, where there is no JS mirror to keep. In React ([section 7](#7-react-rendering-testsrendering)) -both render alike: a Rust machine per row re-renders the 1000-row list in **2.4 ms**, -the same as a TS machine, and one behind `useSelector` in 3.6 ms (TS: 3.8 ms). +both render alike: a Rust machine per row re-renders the 1000-row list in **2.4 ms** +(a TS machine: 2.5 ms), and one behind `useSelector` in 3.6 ms (TS: 3.8 ms). ## Where this actually matters diff --git a/packages/core/README.md b/packages/core/README.md index f1e0669..5274497 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -114,7 +114,7 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers. -In practice that's **~6× the event throughput** of the alternatives (~10× once +In practice that's **~6× the event throughput** of the alternatives (~11× once thousands of observers watch one machine), flat memory as context grows wide, and surgical re-renders that wake only the rows that actually changed. diff --git a/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index 81a2933..ff5d2f2 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -36,16 +36,16 @@ These were run on a **MacBook Pro (M5 Pro, 64 GB)** with Node 24. The engine is - **`n/a ᵃ`**: _async._ Zag's `send` is microtask-batched, so it can't run in a synchronous ops/sec or `flushSync` loop, only where it runs synchronously (construction, memory, React rendering). - **`n/a ᶠ`**: _no equivalent feature._ The engine has no first-class primitive for that scenario (e.g. XState has no lazy/memoized `computed`), so there's nothing comparable to time. -**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 6×–33× gaps are the point. +**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 5×–33× gaps are the point. ## Overview | | Dunky | XState | Zag | | ------------------------- | --------------: | -----: | -----: | -| Event throughput | **9.1 M ops/s** | 1.6 M | n/a ᵃ | -| Memory, 2-field context | **3.5 KB** | 3.6 KB | 8.9 KB | +| Event throughput | **9.7 M ops/s** | 1.6 M | n/a ᵃ | +| Memory, 2-field context | **3.6 KB** | 3.6 KB | 8.9 KB | | Memory, 64-field context | **4.1 KB** | 4.1 KB | 134 KB | -| Re-render wall, 1000 rows | **2.4 ms** | 4.5 ms | n/a ᵃ | +| Re-render wall, 1000 rows | **2.5 ms** | 4.5 ms | n/a ᵃ | ᵃ Zag's `send` is microtask-batched; can't run in a synchronous ops/sec loop. @@ -55,7 +55,7 @@ A single machine, one event, tight loop: | | Dunky | XState | Zag | | ------- | --------: | -----: | ----: | -| ops/sec | **9.1 M** | 1.6 M | n/a ᵃ | +| ops/sec | **9.7 M** | 1.6 M | n/a ᵃ | XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing. @@ -65,8 +65,8 @@ Change a field no observer has selected. The dedup layer re-evaluates and value- | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 1 000 | **5.1 M** | 902.9 K | -| 5 000 | **3.0 M** | 711.7 K | +| 1 000 | **5.1 M** | 1.0 M | +| 5 000 | **4.7 M** | 919.2 K | XState's `actor.subscribe` is coarse: it fires on every snapshot change. To match Dunky's behavior you'd add a differ in the listener, which is what the `xstate` column already does, for a fair comparison. @@ -77,9 +77,9 @@ affected observer" cycles complete per second (higher is better): | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 100 | 653.3 K | 456.9 K | -| 1 000 | 56.1 K | 48.7 K | -| 5 000 | **14.6 K** | 1.4 K | +| 100 | 653.3 K | 451.0 K | +| 1 000 | 56.5 K | 49.3 K | +| 5 000 | **14.7 K** | 1.4 K | Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one. @@ -89,7 +89,7 @@ The whole point of the plain-object model: memory grows with your data, not with | Context width | Dunky | XState | Zag | | ------------- | -----: | -----: | -----: | -| 2 fields | 3.5 KB | 3.6 KB | 8.9 KB | +| 2 fields | 3.6 KB | 3.6 KB | 8.9 KB | | 64 fields | 4.1 KB | 4.1 KB | 134 KB | Going 2 → 64 fields costs Dunky **~0.5 KB/machine**. Zag allocates one reactive cell per field, so a 64-field context grows to **134 KB/machine**. @@ -100,9 +100,9 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 1.43 | **1.10** | 4.86 | +| µs / machine | 1.38 | **1.18** | 5.23 | -XState cold-starts ~1.3× faster; Zag is ~3.4× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). +XState cold-starts ~1.2× faster; Zag is ~3.8× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). ## Machines written in TS vs Rust @@ -110,13 +110,13 @@ Every machine runs on the Rust engine, but you can write it in TypeScript or in | Scenario (ops/s) | TS machine | Rust machine | | ---------------------------------- | ---------: | -----------: | -| One event | **9.4 M** | 6.1 M | -| State churn (exit + entry actions) | **7.2 M** | 6.1 M | -| Guard fallthrough, 8 candidates | **7.8 M** | 5.7 M | -| Command palette, 1,000 commands | **13.1 K** | 8.1 K | -| Command palette, 10,000 commands | **1.3 K** | 786 | +| One event | **10.5 M** | 6.4 M | +| State churn (exit + entry actions) | **9.7 M** | 6.2 M | +| Guard fallthrough, 8 candidates | **8.3 M** | 7.9 M | +| Command palette, 1,000 commands | **13.1 K** | 8.2 K | +| Command palette, 10,000 commands | **1.3 K** | 817 | -In JavaScript, a TS machine is faster: its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. In React both render alike: a 1000-row list re-renders in 2.4 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. +In JavaScript, a TS machine is faster: its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. In React both render alike: a 1000-row list re-renders in about 2.5 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. ## Where this matters From f766dd9c3a893c5c02d1e0093ec5922b7b5c0bf7 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 14:47:50 +0200 Subject: [PATCH 16/28] perf: computed values of TS machines stay in JS 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 --- ARCHITECTURE.md | 4 +- crates/core/SPEC.md | 8 +- crates/core/src/computed.rs | 98 +++++++++++---- crates/core/src/config.rs | 13 +- crates/core/src/lib.rs | 2 + crates/core/src/machine.rs | 13 +- crates/wasm/SPEC.md | 18 ++- crates/wasm/src/lib.rs | 4 +- crates/wasm/src/runtime.rs | 122 +++++++------------ packages/core/src/compile.ts | 3 + packages/core/src/from-wasm.ts | 1 + packages/core/src/machine.ts | 175 +++++++++++++++++++++------ packages/core/tests/computed.test.ts | 69 +++++++++++ 13 files changed, 372 insertions(+), 158 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 719089d..db764bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -214,8 +214,8 @@ crates/core-wasm (inlined in the package) your wasm module call, and — when the machine has no computed values — a whole leaving transition (cleanups, exit, actions, the switch and its notification, entry, effects) is one call. - JS announces its own context writes to the subscribers. The engine hears about a write - only when watchers or computed values need it, and a computed value read again with - no change in between comes from a JS cache, with no call. + only when watchers or computed values need it. JS keeps each computed value with what + it read: a value whose inputs did not change comes from that cache, with no call. - The wasm (`crates/core-wasm`) is inlined in the package and starts synchronously on the first `machine()` call. diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md index 9c2e57a..0452efb 100644 --- a/crates/core/SPEC.md +++ b/crates/core/SPEC.md @@ -146,9 +146,11 @@ The contract: machine) reports which fields a write changed, and announces the write to its own observers. The engine stamps the fields at once, or at its next safe point when it is in the middle of a step, then runs the watchers; it does not notify for that write. -- **Dependencies of external derived values.** The host reports what an evaluation read - — context fields, the state, other derived values — and the engine tracks staleness - from that report. + Without watchers, a report only stamps: it runs no code and cannot fail. +- **External derived values.** The host keeps their values. An evaluation tells the + engine whether the value changed, by the host's own equality, and what it read — + context fields, the state, other derived values — or nothing when it read the same + inputs as the previous evaluation. The engine tracks staleness from what it read. - A machine written in Rust has no external parts: its host only hears changes. ## Performance guarantees diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs index f497fbd..00737b5 100644 --- a/crates/core/src/computed.rs +++ b/crates/core/src/computed.rs @@ -60,6 +60,27 @@ impl std::fmt::Debug for ComputedKey { } } +/// What a host's evaluation of an external computed value reports ([`Host::computed`]). +/// +/// [`Host::computed`]: crate::Host::computed +#[cfg_attr(not(feature = "host"), allow(dead_code))] +pub struct Evaluation { + /// The value changed, by the host's own equality. + pub changed: bool, + /// What the evaluation read, or `None` when it read the same inputs as the previous one. + pub reads: Option, +} + +/// The inputs an evaluation read. +#[cfg_attr(not(feature = "host"), allow(dead_code))] +pub struct Reads { + /// Context fields, one bit each. + pub fields: u64, + pub state: bool, + /// Other computed values, by id. + pub computed: Vec, +} + /// What a computed definition reads. Reads are tracked: `context.()`, `state()` and /// `computed(key)` each become an input of this value. pub struct ComputedParams<'a, T: Types> { @@ -76,19 +97,13 @@ impl ComputedParams<'_, T> { self.core.state } pub fn computed(&self, key: ComputedKey) -> Rc { - self.record_dep(key.id); - downcast(get(self.core, self.inner, key.id), self.inner, key.id) - } - /// Record that this value read computed `id` (for a host that evaluated it itself). - #[cfg(feature = "host")] - pub fn track_computed(&self, id: usize) { - self.record_dep(id); - } - fn record_dep(&self, id: usize) { - let mut deps = self.computed_deps.borrow_mut(); - if !deps.contains(&id) { - deps.push(id); + { + let mut deps = self.computed_deps.borrow_mut(); + if !deps.contains(&key.id) { + deps.push(key.id); + } } + downcast(get(self.core, self.inner, key.id), self.inner, key.id) } } @@ -187,6 +202,11 @@ fn fresh(core: &Core, inner: &Inner, id: usize) -> Option(core: &Core, inner: &Inner, id: usize) -> Rc { let def = &inner.config.0.computed[id]; + let eval = match &def.eval { + ComputedSource::Fn(f) => f, + #[cfg(feature = "host")] + ComputedSource::External(ext) => return recompute_external(core, inner, id, *ext), + }; let slot_cell = &core.computed.slots[id]; let buffer = { let mut slot = slot_cell.borrow_mut(); @@ -222,16 +242,9 @@ fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc f(¶ms), - #[cfg(feature = "host")] - ComputedSource::External(ext) => match inner.host() { - Some(host) => host.computed(*ext, ¶ms), - None => panic!("[machine] computed \"{}\" needs a host", def.name), - }, - }; + let next = eval(¶ms); if inner.aborted() { - // The host failed mid-evaluation: do not cache what it returned. + // A host callback failed mid-evaluation: do not cache what it returned. let mut slot = slot_cell.borrow_mut(); slot.evaluating = false; slot.value = None; @@ -257,3 +270,46 @@ fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc( + core: &Core, + inner: &Inner, + id: usize, + ext: u32, +) -> Rc { + let def = &inner.config.0.computed[id]; + let Some(host) = inner.host() else { + panic!("[machine] computed \"{}\" needs a host", def.name) + }; + let slot_cell = &core.computed.slots[id]; + { + let mut slot = slot_cell.borrow_mut(); + if slot.evaluating { + panic!("[machine] computed \"{}\" depends on itself", def.name); + } + slot.evaluating = true; + } + let evaluation = host.computed(ext); + let mut slot = slot_cell.borrow_mut(); + slot.evaluating = false; + // Kept even after a failure: the host compares its next reads with these. + if let Some(reads) = evaluation.reads { + slot.ctx_deps = reads.fields; + slot.reads_state = reads.state; + slot.computed_deps = reads.computed; + } + if inner.aborted() { + slot.value = None; + return Rc::new(()); + } + let changed = evaluation.changed || slot.value.is_none(); + let marker = slot.value.get_or_insert_with(|| Rc::new(())).clone(); + if changed { + slot.changed_at = core.tick; + } + slot.validated_at = core.tick; + marker +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 6a5f529..38cb916 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -600,19 +600,16 @@ impl ConfigBuilder { ComputedKey::new(id) } - /// A computed value the machine's [`Host`](crate::Host) evaluates (e.g. a JS function - /// of a TS-authored machine). `eq` decides whether a new value changed. Returns its id. + /// A computed value the machine's [`Host`](crate::Host) evaluates and keeps (e.g. a JS + /// function of a TS-authored machine). Returns its id. #[cfg(feature = "host")] - pub fn computed_external( - &mut self, - name: &'static str, - eq: fn(&dyn Any, &dyn Any) -> bool, - ) -> usize { + pub fn computed_external(&mut self, name: &'static str) -> usize { let id = self.inner.computed.len(); self.inner.computed.push(ComputedDef { name, eval: ComputedSource::External(id as u32), - eq, + // The host decides whether a new value changed. + eq: |_, _| false, }); id } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cea694a..6ca3446 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -34,6 +34,8 @@ pub mod testing; pub use broadcast::Subscription; pub use compose::{Combined, Composition, Member}; pub use computed::{ComputedKey, ComputedParams}; +#[cfg(feature = "host")] +pub use computed::{Evaluation, Reads}; pub use config::{ Action, ActionFn, Branch, Cleanup, Config, ConfigBuilder, Delay, Effect, Guard, StateBuilder, TransitionBuilder, diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs index d7a8799..363858f 100644 --- a/crates/core/src/machine.rs +++ b/crates/core/src/machine.rs @@ -16,7 +16,7 @@ use std::collections::VecDeque; use std::rc::{Rc, Weak}; use crate::broadcast::{Broadcast, Subscription}; -use crate::computed::{self, ComputedKey, ComputedParams, ComputedRuntime}; +use crate::computed::{self, ComputedKey, ComputedRuntime, Evaluation}; use crate::config::{ Action, Cleanup, Config, Delay, Effect, Entry, Guard, Transition, WatchSource, missing, }; @@ -48,8 +48,9 @@ pub trait Host { /// switches state. fn stop_effects(&self); fn delay(&self, id: u32, event: Option<&T::Event>) -> u32; - /// Evaluate computed value `id`, reporting what it read through `params`. - fn computed(&self, id: u32, params: &ComputedParams<'_, T>) -> Rc; + /// Evaluate external computed value `id`. The host keeps the value; the engine keeps + /// what the evaluation read, to tell when the value goes stale. + fn computed(&self, id: u32) -> Evaluation; /// Evaluate the guards of candidate list `list` in order: the index of the first that /// passes (a candidate without a guard passes). fn pick(&self, list: u32, event: Option<&T::Event>) -> Option; @@ -388,7 +389,8 @@ impl Machine { /// A host changed fields of a context it owns (a TS machine's JS context): stamp them /// and run the watchers — right away when possible, else at the next safe point. The - /// host notifies its own observers of the write. + /// host notifies its own observers of the write. Without watchers it only stamps: no + /// code runs. #[cfg(feature = "host")] pub fn mark_changed(&self, mask: u64) { if mask == 0 { @@ -406,6 +408,9 @@ impl Machine { inner.external_mask.set(inner.external_mask.get() | mask); return; } + if inner.config.0.watch.is_empty() { + return; + } inner.with_flush(|inner| { if let Ok(core) = inner.core.try_borrow() { inner.detect_watched(&core, mask); diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md index 3299f5a..d271b19 100644 --- a/crates/wasm/SPEC.md +++ b/crates/wasm/SPEC.md @@ -73,7 +73,10 @@ machines; the conversion helpers between JS values and Rust values. - `guard`; `pick`, a guard walk in one call (the first candidate that passes); - `action`; `actions`, a run of actions in one call; - `effect`: starts an effect, and the facade keeps its cleanup; - - `delay`; `computed`, which reports its value and its reads back before it returns; + - `delay`; + - `computed`: runs a derived definition. The value stays in JS, and the status says + whether it changed (`Object.is`). What the definition read crosses before it + returns, and only when it differs from the previous evaluation; - `transition`: a leaving transition run whole (see below). - What the engine holds back rides on the next `action`, `actions`, `effect` or `transition` call as `pre`, and runs first: bit 0 stops the effects (the facade runs @@ -105,7 +108,7 @@ machines; the conversion helpers between JS values and Rust values. ### Values - **TS machines** keep JS values: the context is a JS object, and a derived value is a - JS value compared with `Object.is`. + JS value, kept in JS and compared with `Object.is`. - **Rust machines** convert with serde: `None` becomes `null`. A derived value is serialized only when its version changed, and the same JS value is served until then, so its identity marks a change. @@ -117,10 +120,13 @@ machines; the conversion helpers between JS values and Rust values. - The context lives in JS, and JS announces its own writes to the subscribers. It reports a write's change mask to Rust only when the machine has watchers or derived values: one bit per field, 64 bits, and every field past the 63rd shares the last bit. - JS also reports what each derived evaluation read: fields, the state, other derived - values. -- Every input of a derived value changes through JS, so JS serves a value read again - with no change in between without calling Rust. + Without watchers the report only stamps, so it is a plain call: no facade, no status. +- JS keeps each derived value with what its last evaluation read: fields, the state, + other derived values. A write clears the values that read a written field, a state + change clears the values that read the state, and a cleared value clears the values + that read it. JS serves a value that is not cleared without calling Rust; for a + cleared one it asks Rust, which checks again and evaluates only what changed. Every + input of a derived value changes through JS, so the cache misses no change. - The event object crosses as is: the engine carries it — also for a timed transition scheduled by that event — and hands it back to the callbacks. diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 531b571..c495568 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -17,7 +17,7 @@ use std::cell::RefCell; use std::rc::Rc; use dunky_core::{ - Changes, ComputedKey, ComputedParams, Context, DeserializeEvent, EventEnum, Host, Machine, + Changes, ComputedKey, Context, DeserializeEvent, Evaluation, EventEnum, Host, Machine, SerializeFields, StateEnum, Types, }; use serde::Serialize; @@ -95,7 +95,7 @@ impl Host for Observer { fn delay(&self, _: u32, _: Option<&T::Event>) -> u32 { unreachable!("a Rust machine has no external delays") } - fn computed(&self, _: u32, _: &ComputedParams<'_, T>) -> Rc { + fn computed(&self, _: u32) -> Evaluation { unreachable!("a Rust machine has no external computed values") } fn pick(&self, _: u32, _: Option<&T::Event>) -> Option { diff --git a/crates/wasm/src/runtime.rs b/crates/wasm/src/runtime.rs index 9c235fb..5d5f28c 100644 --- a/crates/wasm/src/runtime.rs +++ b/crates/wasm/src/runtime.rs @@ -5,22 +5,22 @@ //! user code — guards, actions, effects, delays, computed definitions — through the //! host functions (see `crate::bridge`), by the callback's number in the JS-side tables. //! -//! The context lives in JS (it holds arbitrary JS values). JS reports which fields a -//! write changed (`markChanged`) and which fields a computed value read (`reportDeps`); -//! Rust keeps the change stamps that drive watchers and computed staleness. +//! The context and the computed values live in JS (they hold arbitrary JS values). JS +//! reports which fields a write changed (`markChanged`, or `stamp` without watchers) and +//! what a computed evaluation read (`report`); Rust keeps the change stamps that drive +//! watchers and computed staleness. -use std::any::Any; use std::cell::{Cell, RefCell}; use std::rc::Rc; use dunky_core::{ - Action, Branch, Changes, ComputedParams, Config, Context, Delay, Effect, EventEnum, Field, - Guard, Host, HostTransition, Machine, StateEnum, TransitionBuilder, Types, + Action, Branch, Changes, Config, Context, Delay, Effect, Evaluation, EventEnum, Field, Guard, + Host, HostTransition, Machine, Reads, StateEnum, TransitionBuilder, Types, }; use serde::Deserialize; use wasm_bindgen::prelude::*; -use crate::bridge::{Bridge, HostObject, Link, OK}; +use crate::bridge::{Bridge, FAILED, HostObject, Link, OK}; // --------------------------------------------------------------------------------- // The machine type of every TS-authored machine: states and event types are numbers @@ -68,28 +68,15 @@ impl EventEnum for JsEvent { #[derive(Clone, Default)] pub struct JsContext; -/// Records the fields a computed value read, as reported by JS. -#[derive(Clone, Copy)] -pub struct JsReader<'a> { - deps: &'a Cell, -} - -impl JsReader<'_> { - fn mark(&self, fields: u64) { - self.deps.set(self.deps.get() | fields); - } -} - impl Context for JsContext { type Patch = u64; - type Reader<'a> = JsReader<'a>; + // JS evaluates the computed values and reports what they read. + type Reader<'a> = (); const FIELDS: &'static [&'static str] = &[]; fn apply(&mut self, changed: u64) -> u64 { changed } - fn reader<'a>(&'a self, deps: &'a Cell) -> JsReader<'a> { - JsReader { deps } - } + fn reader<'a>(&'a self, _deps: &'a Cell) {} } pub struct Js; @@ -230,14 +217,6 @@ fn candidate(mut t: TransitionBuilder, spec: &CandidateSpec) -> TransitionBu t } -/// Computed values of a TS machine compare like the TS engine: `Object.is`. -fn js_is(a: &dyn Any, b: &dyn Any) -> bool { - match (a.downcast_ref::(), b.downcast_ref::()) { - (Some(a), Some(b)) => js_sys::Object::is(a, b), - _ => false, - } -} - fn build(spec: &Spec) -> Config { let mut b = Config::::builder_sized( DynState(spec.initial), @@ -246,7 +225,7 @@ fn build(spec: &Spec) -> Config { spec.kinds as usize, ); for _ in 0..spec.computed { - b.computed_external("", js_is); + b.computed_external(""); } for (index, state) in spec.states.iter().enumerate() { b.state(DynState(index as u32), |mut s| { @@ -331,7 +310,9 @@ extern "C" { /// The delay in ms, or -1 when it failed. #[wasm_bindgen(method)] fn delay(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> f64; - /// 0 once the value is reported (`JsMachine::report`), or 1 when it failed. + /// 0, `CHANGED` when the value changed (it stays in JS), or 1 when it failed. What + /// the evaluation read arrives first (`JsMachine::report`) when it differs from the + /// previous evaluation. #[wasm_bindgen(method)] fn computed(this: &HostObject, facade: &JsValue, id: u32) -> u32; /// The index of the first candidate of guard list `list` that passes, -1 for none, @@ -358,17 +339,13 @@ extern "C" { ) -> u32; } -/// What one computed evaluation returned and read, reported by JS before it returns. -struct Report { - value: JsValue, - fields: u64, - state: bool, - computed: Option>, -} +/// The host's `computed` status bit: the value changed. +const CHANGED: u32 = 2; struct JsHost { link: Rc, - report: RefCell>, + /// What the computed evaluation in progress read, when JS reported it. + report: RefCell>, /// What rides on the next action or effect call, delivered first (one crossing /// instead of two), or at `settle`: bit 0 stops the effects, the bits above hold a /// state change (the state + 1; 0 for none). @@ -451,22 +428,16 @@ impl Host for JsHost { ms as u32 } - fn computed(&self, id: u32, params: &ComputedParams<'_, Js>) -> Rc { + fn computed(&self, id: u32) -> Evaluation { self.deliver(); - if self.link.host.computed(&self.link.facade(), id) != OK { + let status = self.link.host.computed(&self.link.facade(), id); + if status & FAILED != 0 { self.link.fail(); } - let Some(report) = self.report.borrow_mut().take() else { - return Rc::new(JsValue::UNDEFINED); - }; - params.context.mark(report.fields); - if report.state { - params.state(); - } - for c in report.computed.into_iter().flatten() { - params.track_computed(c as usize); + Evaluation { + changed: status & CHANGED != 0, + reads: self.report.borrow_mut().take(), } - Rc::new(report.value) } fn pick(&self, list: u32, event: Option<&JsEvent>) -> Option { @@ -606,8 +577,8 @@ impl JsMachine { self.bridge.machine().state().0 } - /// JS changed context fields (`lo`/`hi`: the low and high 32 bits of the mask). Only - /// a machine with watchers or computed values needs to say so. + /// JS changed context fields (`lo`/`hi`: the low and high 32 bits of the mask), and + /// the machine has watchers: they may run. #[wasm_bindgen(js_name = markChanged)] pub fn mark_changed(&self, lo: u32, hi: u32, facade: JsValue) -> u32 { self.bridge.call(facade, |m| { @@ -615,31 +586,32 @@ impl JsMachine { }) } - /// The value of computed `id` (`undefined` when its definition failed). - pub fn computed(&self, id: u32, facade: JsValue) -> JsValue { - let mut value = JsValue::UNDEFINED; + /// JS changed context fields, and the machine has computed values but no watchers: + /// only stamp them. No code runs, so there is no facade and no status. + pub fn stamp(&self, lo: u32, hi: u32) { + self.bridge + .machine() + .mark_changed((u64::from(hi) << 32) | u64::from(lo)); + } + + /// Bring computed `id` up to date, evaluating what changed. JS holds the values. + pub fn computed(&self, id: u32, facade: JsValue) -> u32 { self.bridge.call(facade, |m| { - if let Some(v) = m.computed_any(id as usize).downcast_ref::() { - value = v.clone(); - } - }); - value + m.computed_version(id as usize); + }) } - /// What the computed evaluation in progress returned and read. - pub fn report( - &self, - value: JsValue, - lo: u32, - hi: u32, - state: bool, - computed: Option>, - ) { - *self.host.report.borrow_mut() = Some(Report { - value, + /// What the computed evaluation in progress read, when it differs from the previous + /// evaluation (`lo`/`hi`: the fields; `state`; the other computed values). + pub fn report(&self, lo: u32, hi: u32, state: bool, computed: Option>) { + *self.host.report.borrow_mut() = Some(Reads { fields: (u64::from(hi) << 32) | u64::from(lo), state, - computed, + computed: computed + .into_iter() + .flatten() + .map(|id| id as usize) + .collect(), }); } diff --git a/packages/core/src/compile.ts b/packages/core/src/compile.ts index ab74a05..72e93de 100644 --- a/packages/core/src/compile.ts +++ b/packages/core/src/compile.ts @@ -57,6 +57,8 @@ export interface Compiled { fieldBits: Map /** Whether the engine needs to hear about context writes (watchers, computed values). */ tracksContext: boolean + /** Whether a context write can run code in the engine (watchers). */ + watches: boolean computedIndex: Map computedDefs: Fn[] guards: Fn[] @@ -261,6 +263,7 @@ export function compile(config: AnyConfig): Compiled { tags: stateNames.map(name => new Set(config.states[name]!.tags ?? [])), fieldBits, tracksContext: watch.length > 0 || computedNames.length > 0, + watches: watch.length > 0, computedIndex, computedDefs: computedNames.map(name => (config.computed as Record)[name]!), guards, diff --git a/packages/core/src/from-wasm.ts b/packages/core/src/from-wasm.ts index 1cc8203..6e5ee83 100644 --- a/packages/core/src/from-wasm.ts +++ b/packages/core/src/from-wasm.ts @@ -6,6 +6,7 @@ export interface WasmMachine extends Engine { /** Connect to the host; `facade` is the facade calling. Returns a status. */ attach: (host: object, facade: object) => number field: (index: number) => unknown + computed: (id: number, facade: object) => unknown meta: () => WasmMachineMeta } diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 432fa77..0979e5c 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -25,7 +25,6 @@ export interface Engine { stop: (facade: object) => number running: () => boolean state: () => number - computed: (id: number, facade: object) => unknown /** The host clock: timer `id` came due. */ fire: (id: number, facade: object) => number /** The engine's failure behind `ENGINE_FAILED`. */ @@ -37,6 +36,8 @@ export interface Engine { const OK = 0 const FAILED = 1 const ENGINE_FAILED = 3 +/** The host's `computed` status bit: the value changed. */ +const CHANGED = 2 const NONE = Symbol('none') /** The names the facade maps the engine's numbers onto. */ @@ -74,9 +75,6 @@ export class MachineClass< selectFacade: Select | null = null /** The first error user code threw in the current call. */ error: unknown = NONE - /** Bumped on every change (a context write, a state change): a computed value read at - * the same epoch is still valid. */ - epoch = 0 /** Nesting of engine calls: JS code calls back in from inside one. */ depth = 0 /** Started and not stopped (mirrors the engine, so JS needs no call to ask). */ @@ -94,7 +92,6 @@ export class MachineClass< /** The machine is in `state` now: tell the subscribers. */ enter(state: number): void { this.current = state - this.epoch++ this.broadcast.notify() } @@ -200,6 +197,58 @@ function markRead(bit: number): void { else readHi |= 1 << (bit - 32) } +/** What JS keeps of one computed value of a TS machine. */ +interface CacheEntry { + value: unknown + /** No input changed since the value was evaluated or checked. */ + valid: boolean + /** What the last evaluation read: fields (the low and high 32 bits of the mask), the + * state, other computed values. */ + lo: number + hi: number + state: boolean + computed: number[] | null + /** The computed values that read this one. */ + readers: number[] | null + /** Rust has the reads (false until the first evaluation). */ + reported: boolean + /** The last clearing pass that reached this entry. */ + pass: number +} + +function newEntry(): CacheEntry { + return { + value: undefined, + valid: false, + lo: 0, + hi: 0, + state: false, + computed: null, + readers: null, + reported: false, + pass: 0, + } +} + +let clearPass = 0 + +// Walk past an entry that is already cleared: Rust may have checked its readers since +// (an input changed but kept its value), so they can be valid. +function clearEntry(cache: CacheEntry[], entry: CacheEntry, pass: number): void { + if (entry.pass === pass) return + entry.pass = pass + entry.valid = false + const readers = entry.readers + if (readers) for (let i = 0; i < readers.length; i++) clearEntry(cache, cache[readers[i]!]!, pass) +} + +function sameIds(a: number[] | null, b: number[] | null): boolean { + if (a === b) return true + if (!a || !b || a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + // The params of the dispatch in progress: its guards and actions share them. One slot // for all machines, so no machine keeps a dispatch's params alive. let paramsOf: object | null = null @@ -231,9 +280,8 @@ class TsMachine< declare handle: JsMachine /** The cleanups of the effects started in the current state, in start order. */ effectCleanups: Array<() => void> = [] - /** Per computed value: the epoch it was last read at, and the value. */ - readAt: number[] - readValue: unknown[] + /** Per computed value, its cached value and inputs; null without computed values. */ + cache: CacheEntry[] | null tracked: { context: Context; computed: Computed; readonly state: State } | null = null constructor(c: Compiled, context: Context) { @@ -241,8 +289,7 @@ class TsMachine< // Refs captured in effects/actions always see the live context. super(c, { ...context }, c.initial) this.c = c - this.readAt = c.computedDefs.map(() => -1) - this.readValue = c.computedDefs.map(() => undefined) + this.cache = c.computedDefs.length === 0 ? null : c.computedDefs.map(newEntry) for (const [name, id] of c.computedIndex) { Object.defineProperty(this.computed, name, { enumerable: true, get: () => this.read(id) }) } @@ -262,10 +309,7 @@ class TsMachine< ctx[key] = value // in place — ctx identity never changes changed = true } - if (changed) { - this.epoch++ - this.broadcast.notify() - } + if (changed) this.broadcast.notify() return } let lo = 0 @@ -279,27 +323,51 @@ class TsMachine< else hi |= 1 << (bit - 32) } if ((lo | hi) === 0) return - this.epoch++ - this.depth++ - const status = this.handle.markChanged(lo >>> 0, hi >>> 0, this) - this.depth-- - if (status !== OK) this.raise(status) + const read = this.cache !== null && this.clear(lo, hi, false) + if (this.c.watches) { + this.depth++ + const status = this.handle.markChanged(lo >>> 0, hi >>> 0, this) + this.depth-- + if (status !== OK) this.raise(status) + } else if (read) { + // The engine's stamps only matter for fields a computed value read. + this.handle.stamp(lo >>> 0, hi >>> 0) + } this.broadcast.notify() } + override enter(state: number): void { + if (this.cache) this.clear(0, 0, true) + super.enter(state) + } + + /** Clear the cached values that read a field of `lo`/`hi` (or the state, with + * `state`), and the values that read a cleared one. True when one read them. */ + clear(lo: number, hi: number, state: boolean): boolean { + const cache = this.cache! + const pass = ++clearPass + let read = false + for (let i = 0; i < cache.length; i++) { + const entry = cache[i]! + if ((entry.lo & lo) !== 0 || (entry.hi & hi) !== 0 || (state && entry.state)) { + read = true + clearEntry(cache, entry, pass) + } + } + return read + } + /** Read computed `id`; throw if its definition failed during this read. */ read(id: number): unknown { - // Every input of a TS machine changes through JS, so an unchanged epoch means an - // unchanged value: no call into the engine. - if (this.readAt[id] === this.epoch) return this.readValue[id] - const before = this.error + // Every input of a TS machine changes through JS, so a valid entry is the value. + const entry = this.cache![id]! + if (entry.valid) return entry.value this.depth++ - const value = this.handle.computed(id, this) + const status = this.handle.computed(id, this) this.depth-- - if (this.error !== before) this.raise(FAILED) - this.readAt[id] = this.epoch - this.readValue[id] = value - return value + if (status !== OK) this.raise(status) + entry.valid = true + return entry.value } /** Run compiled action specs in order: callbacks, runs of callbacks, `oneOf`s. */ @@ -376,8 +444,11 @@ class TsMachine< )) as GuardParams } - /** Run computed `id`; report its value and what it read to Rust, which owns its staleness. */ + /** Run computed `id` for the engine, which owns its staleness. The value stays here; + * the status says whether it changed, and what it read crosses only when it differs + * from the previous evaluation. */ evaluate(id: number): number { + const entry = this.cache![id]! const lo = readLo const hi = readHi const state = readsState @@ -386,16 +457,36 @@ class TsMachine< readHi = 0 readsState = false reads = null + entry.valid = false try { const value = this.c.computedDefs[id]!(this.trackedParams()) - this.handle.report( - value, - readLo >>> 0, - readHi >>> 0, - readsState, - reads ? Uint32Array.from(reads) : undefined, - ) - return OK + const changed = !Object.is(entry.value, value) + entry.value = value + entry.valid = true + const sameComputed = sameIds(reads, entry.computed) + if ( + !entry.reported || + !sameComputed || + readLo !== entry.lo || + readHi !== entry.hi || + readsState !== entry.state + ) { + entry.reported = true + entry.lo = readLo + entry.hi = readHi + entry.state = readsState + if (!sameComputed) { + entry.computed = reads + this.linkReaders() + } + this.handle.report( + readLo >>> 0, + readHi >>> 0, + readsState, + reads ? Uint32Array.from(reads) : undefined, + ) + } + return changed ? CHANGED : OK } finally { readLo = lo readHi = hi @@ -403,6 +494,15 @@ class TsMachine< reads = outer } } + /** Rebuild who reads whom, after an evaluation read other computed values. */ + linkReaders(): void { + const cache = this.cache! + for (let i = 0; i < cache.length; i++) cache[i]!.readers = null + for (let i = 0; i < cache.length; i++) { + const read = cache[i]!.computed + if (read) for (let j = 0; j < read.length; j++) (cache[read[j]!]!.readers ??= []).push(i) + } + } trackedParams(): { context: Context; computed: Computed; readonly state: State } { if (this.tracked) return this.tracked const { c, handle } = this @@ -455,6 +555,7 @@ interface Host { pre: (m: TsFacade, pre: number) => number /** The delay in ms, -1 failed. */ delay: (m: TsFacade, fn: number, event: unknown) => number + /** 0, `CHANGED` when the value changed (it stays in JS), 1 failed. */ computed: (m: TsFacade, id: number) => number /** The first candidate of guard list `list` that passes, -1 for none, -2 failed. */ pick: (m: TsFacade, list: number, event: unknown) => number diff --git a/packages/core/tests/computed.test.ts b/packages/core/tests/computed.test.ts index 30b3e34..44c1d01 100644 --- a/packages/core/tests/computed.test.ts +++ b/packages/core/tests/computed.test.ts @@ -58,6 +58,50 @@ describe('from context', () => { expect(seen).toEqual([true, false]) }) + it('a read right after a write, in the same action, sees the write', () => { + let seen: number | undefined + const m = machine<'idle', { n: number }, { type: 'inc' }, { double: number }>({ + initial: 'idle', + context: { n: 1 }, + computed: { double: ({ context }) => context.n * 2 }, + states: { + idle: { + on: { + inc: { + actions: [ + ({ context, setContext, computed }) => { + setContext({ n: context.n + 1 }) + seen = computed.double + }, + ], + }, + }, + }, + }, + }) + expect(m.computed.double).toBe(2) // cached before the write + m.send({ type: 'inc' }) + expect(seen).toBe(4) + }) + + it('a definition that throws rethrows on read, and runs again on the next read', () => { + let ready = false + const m = machine<'idle', { n: number }, { type: 'noop' }, { checked: number }>({ + initial: 'idle', + context: { n: 1 }, + computed: { + checked: ({ context }) => { + if (!ready) throw new Error('not ready') + return context.n + }, + }, + states: { idle: {} }, + }) + expect(() => m.computed.checked).toThrow('not ready') + ready = true + expect(m.computed.checked).toBe(1) + }) + it('is available to a guard', () => { const m = machine< 'idle' | 'done', @@ -253,6 +297,31 @@ describe('chaining — computed from computed', () => { m.send({ type: 'read' }) expect([baseRuns, derivedRuns]).toEqual([2, 2]) }) + + it('a chain updates after an upstream value that stayed equal', () => { + const m = machine< + 'idle', + { n: number }, + { type: 'set'; n: number }, + { parity: string; label: string; shout: string } + >({ + initial: 'idle', + context: { n: 1 }, + computed: { + parity: ({ context }) => (context.n % 2 ? 'odd' : 'even'), + label: ({ computed }) => `n is ${computed.parity}`, + shout: ({ computed }) => computed.label.toUpperCase(), + }, + states: { + idle: { on: { set: { actions: [({ event, setContext }) => setContext({ n: event.n })] } } }, + }, + }) + expect(m.computed.shout).toBe('N IS ODD') + m.send({ type: 'set', n: 3 }) // parity stays 'odd', so label and shout keep their values + expect(m.computed.shout).toBe('N IS ODD') + m.send({ type: 'set', n: 4 }) + expect(m.computed.shout).toBe('N IS EVEN') + }) }) describe('surfaced on the machine', () => { From f9855812de5639797f577a7c318f9c89f9c9b9c4 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 14:53:42 +0200 Subject: [PATCH 17/28] perf(benchmark): numbers after the computed fix 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 --- README.md | 6 +- benchmark/README.md | 104 ++++++++++++------------- packages/core/README.md | 2 +- website/src/content/docs/benchmark.mdx | 42 +++++----- 4 files changed, 77 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 7f147d6..c606e16 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,9 @@ engine is built for it: | At scale (thousands of machines) | Dunky | XState | Zag | | -------------------------------- | --------: | -----: | ------: | -| Event throughput (ops/s) | **9.7 M** | 1.6 M | n/a ᵃ | -| Memory / machine, 2-field (KB) | **3.6** | 3.6 | 8.9 | -| Memory / machine, 64-field (KB) | **4.1** | 4.1 | **134** | +| Event throughput (ops/s) | **9.4 M** | 1.6 M | n/a ᵃ | +| Memory / machine, 2-field (KB) | **3.5** | 3.6 | 8.9 | +| Memory / machine, 64-field (KB) | **4.0** | 4.1 | **134** | → **~6× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the **[benchmark README](./benchmark/README.md)**. diff --git a/benchmark/README.md b/benchmark/README.md index b3926ad..c6cbe4b 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -97,11 +97,11 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 653.3 K | 451.0 K | n/a ᵃ | -| 1000 | 56.5 K | 49.3 K | n/a ᵃ | -| 5000 | **14.7 K** | 1.4 K | n/a ᵃ | +| 100 | 663.3 K | 455.2 K | n/a ᵃ | +| 1000 | 58.4 K | 50.0 K | n/a ᵃ | +| 5000 | **21.2 K** | 1.4 K | n/a ᵃ | -→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's +→ Roughly par at small N, but Dunky **~15× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. **B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The @@ -111,8 +111,8 @@ model gets for free and a coarse bus has to work to ignore. | Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **5.1 M** | 1.0 M | n/a ᵃ | -| 5000 | **4.7 M** | 919.2 K | n/a ᵃ | +| 1000 | **4.9 M** | 1.0 M | n/a ᵃ | +| 5000 | **4.7 M** | 942.2 K | n/a ᵃ | → Dunky is **~5× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. @@ -122,7 +122,7 @@ selection scaling — the raw `send` price. | Single machine, one event | ops/sec | | ------------------------- | --------: | -| Dunky | **9.7 M** | +| Dunky | **9.4 M** | | XState (raw) | 1.6 M | | XState (diffed) | 1.6 M | | Zag | n/a ᵃ | @@ -144,12 +144,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 9.1 M | 9.2 M | n/a ᶠ | n/a ᶠ | -| 10 | 7.9 M | 8.4 M | n/a ᶠ | n/a ᶠ | -| 50 | 7.4 M | 7.7 M | n/a ᶠ | n/a ᶠ | +| 2 | 8.9 M | 9.1 M | n/a ᶠ | n/a ᶠ | +| 10 | 8.0 M | 8.6 M | n/a ᶠ | n/a ᶠ | +| 50 | 7.3 M | 7.1 M | n/a ᶠ | n/a ᶠ | -→ Cross-region coordination stays in the **~7.4–9.2 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~16–19% going from 2 to 50. +→ Cross-region coordination stays in the **~7.1–9.1 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~18–22% going from 2 to 50. > A third "chain" sub-test (a sync rule that `send()`s downstream every change) was > removed: under a tight loop it shows superlinear slowdown. That's a real @@ -165,13 +165,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z | Scenario | Dunky (ops/sec) | XState | Zag | | ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **56.3 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 5.7 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 3.4 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 982.4 K | n/a ᶠ | n/a ᵃ | +| Cached read (no change) | **57.8 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 8.3 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 4.5 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 2.3 M | n/a ᶠ | n/a ᵃ | -→ A cached read is **~56.3 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~5.7 M/sec — read-key tracking means +→ A cached read is **~57.8 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~8.3 M/sec — read-key tracking means you only pay the recompute when an input you actually read changes. ## 4. Engine hot paths (`tests/engine.ts`) @@ -183,18 +183,18 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 4.4 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 2 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | | Guard fallthrough — 8 candidates | 4.3 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 3.9 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 7.5 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 6.7 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 10.0 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 7.1 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 4.0 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 8.0 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 7.1 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 9.8 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 7.2 M | n/a ᶠ | n/a ᵃ | -→ Even the heavy paths hold **~3.9–10.0 M ops/sec**: a 32-candidate guard walk +→ Even the heavy paths hold **~4.0–9.8 M ops/sec**: a 32-candidate guard walk (evaluated in one call from the engine into JS), full state transitions with entry/exit actions, and effect boot/cleanup every transition all stay within -~2.5× of a bare `send`. +~2.3× of a bare `send`. ## 5. Construction cost (`tests/construct.ts`) @@ -204,11 +204,11 @@ warmed first. | Build + start | Dunky (µs/machine) | XState | Zag | | ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.38 | 1.18 | 5.23 | +| 10 000 | 1.35 | 1.33 | 5.52 | → Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.8× faster than Zag's per-field reactive cells. +as fast or slightly faster (~1.0×). Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~4.1× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) @@ -220,12 +220,12 @@ the footprint a churny app actually pays). | Context | Dunky (KB/machine) | XState | Zag | | -------- | -----------------: | -----: | ------: | -| 2-field | 3.56 | 3.61 | 8.92 | -| 64-field | 4.08 | 4.10 | **134** | +| 2-field | 3.47 | 3.61 | 8.92 | +| 64-field | 3.99 | 4.10 | **134** | → Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with the data you store, not with a per-field cell. **Zag is the contrast**: one reactive -cell per field balloons the 64-field context to ~134 KB/machine — **~33× more** than +cell per field balloons the 64-field context to ~134 KB/machine — **~34× more** than Dunky. **Idle vs written.** Dunky owns its context copy from construction and mutates @@ -234,8 +234,8 @@ while a lazy-copy scheme steps up once writes start: | 64-field, 5000 machines | Dunky | XState | Zag | | ----------------------- | ----: | -----: | --: | -| Idle (never written) | 4.06 | 3.55 | 130 | -| Written (1 event each) | 4.08 | 4.10 | 134 | +| Idle (never written) | 3.97 | 3.55 | 130 | +| Written (1 event each) | 3.99 | 4.10 | 134 | → Dunky idle ≈ written; XState's first `assign` allocates a per-actor context, so its written row grows. @@ -253,15 +253,15 @@ List of 1000 rows: | Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | | -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 3.9 | **2.5** | -| Dunky/selector | 2 | 4.8 | 3.8 | -| xstate/selector | 2 | 3.8 | 4.5 | -| zag/instance | 2 | 4.2 | n/a ᵃ | -| naive (anti-pattern) | **980** | 4.8 | 36.5 | +| Dunky/instance | **2** | 3.6 | **2.4** | +| Dunky/selector | 2 | 5.0 | 3.8 | +| xstate/selector | 2 | 3.6 | 4.5 | +| zag/instance | 2 | 4.1 | n/a ᵃ | +| naive (anti-pattern) | **980** | 5.0 | 35.3 | → Every properly-set-up engine wakes only the **2** rows that changed (vs. the naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~15× the wall time). Among the surgical strategies Dunky re-renders **~1.8× +~15× the wall time). Among the surgical strategies Dunky re-renders **~1.9× faster than XState**. Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the @@ -278,26 +278,26 @@ wasm handle with no facade: the floor. A call into wasm costs the same as a JS c | Scenario | TS machine (ops/sec) | Rust machine | Rust, raw | | ----------------------------------------- | -------------------: | -----------: | --------: | -| One event, observed field | **10.5 M** | 6.4 M | 13.1 M | -| One event, unobserved field | **10.2 M** | 6.2 M | 13.0 M | -| State churn — exit+entry every event | **9.7 M** | 6.2 M | 8.9 M | -| Guard fallthrough — 8 candidates | **8.3 M** | 7.9 M | — | -| Palette — type + read, 11 commands | **778.3 K** | 427.0 K | — | -| Palette — type + read, 1,000 commands | **13.1 K** | 8.2 K | — | -| Palette — type + read, 10,000 commands | **1.3 K** | 817 | — | -| Construct + start one machine (µs / mach) | **4.86** | 4.93 | 2.08 | - -→ In JavaScript, a machine written in TS handles **~1.1–1.6× the events** of its Rust +| One event, observed field | **9.8 M** | 6.6 M | 13.4 M | +| One event, unobserved field | **9.9 M** | 6.3 M | 13.3 M | +| State churn — exit+entry every event | **9.5 M** | 6.4 M | 9.1 M | +| Guard fallthrough — 8 candidates | **8.2 M** | 8.0 M | — | +| Palette — type + read, 11 commands | **830.2 K** | 429.2 K | — | +| Palette — type + read, 1,000 commands | **13.3 K** | 8.4 K | — | +| Palette — type + read, 10,000 commands | **1.3 K** | 836 | — | +| Construct + start one machine (µs / mach) | **4.56** | 5.37 | 2.05 | + +→ In JavaScript, a machine written in TS handles **~1.0–1.6× the events** of its Rust twin. Its context already lives in JS, while a Rust machine pushes each change back across the boundary: a notify, then a read per changed field to refresh the JS context mirror. The raw handle (no subscribers, no mirror) shows the engine itself: ~13 M events/sec. -→ Heavy derived data favors TS too: the palette filters **~1.6–1.8× faster** as a TS +→ Heavy derived data favors TS too: the palette filters **~1.6–1.9× faster** as a TS machine, because its filter runs as JIT-compiled JS and its results are already JS values, while the Rust machine serializes its results on each change. Crossing as indices mapped back onto your own objects (the rows above) beats crossing whole -objects by ~22% (6.7 K at 1,000 commands). +objects by ~22% (6.9 K at 1,000 commands). → Rust machines are for reuse: the same machine runs in a Rust program and over JSI, where there is no JS mirror to keep. In React ([section 7](#7-react-rendering-testsrendering)) diff --git a/packages/core/README.md b/packages/core/README.md index 5274497..499b8c8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -114,7 +114,7 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers. -In practice that's **~6× the event throughput** of the alternatives (~11× once +In practice that's **~6× the event throughput** of the alternatives (~15× once thousands of observers watch one machine), flat memory as context grows wide, and surgical re-renders that wake only the rows that actually changed. diff --git a/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index ff5d2f2..bfb2637 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -36,16 +36,16 @@ These were run on a **MacBook Pro (M5 Pro, 64 GB)** with Node 24. The engine is - **`n/a ᵃ`**: _async._ Zag's `send` is microtask-batched, so it can't run in a synchronous ops/sec or `flushSync` loop, only where it runs synchronously (construction, memory, React rendering). - **`n/a ᶠ`**: _no equivalent feature._ The engine has no first-class primitive for that scenario (e.g. XState has no lazy/memoized `computed`), so there's nothing comparable to time. -**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 5×–33× gaps are the point. +**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 5×–34× gaps are the point. ## Overview | | Dunky | XState | Zag | | ------------------------- | --------------: | -----: | -----: | -| Event throughput | **9.7 M ops/s** | 1.6 M | n/a ᵃ | -| Memory, 2-field context | **3.6 KB** | 3.6 KB | 8.9 KB | -| Memory, 64-field context | **4.1 KB** | 4.1 KB | 134 KB | -| Re-render wall, 1000 rows | **2.5 ms** | 4.5 ms | n/a ᵃ | +| Event throughput | **9.4 M ops/s** | 1.6 M | n/a ᵃ | +| Memory, 2-field context | **3.5 KB** | 3.6 KB | 8.9 KB | +| Memory, 64-field context | **4.0 KB** | 4.1 KB | 134 KB | +| Re-render wall, 1000 rows | **2.4 ms** | 4.5 ms | n/a ᵃ | ᵃ Zag's `send` is microtask-batched; can't run in a synchronous ops/sec loop. @@ -55,7 +55,7 @@ A single machine, one event, tight loop: | | Dunky | XState | Zag | | ------- | --------: | -----: | ----: | -| ops/sec | **9.7 M** | 1.6 M | n/a ᵃ | +| ops/sec | **9.4 M** | 1.6 M | n/a ᵃ | XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing. @@ -65,8 +65,8 @@ Change a field no observer has selected. The dedup layer re-evaluates and value- | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 1 000 | **5.1 M** | 1.0 M | -| 5 000 | **4.7 M** | 919.2 K | +| 1 000 | **4.9 M** | 1.0 M | +| 5 000 | **4.7 M** | 942.2 K | XState's `actor.subscribe` is coarse: it fires on every snapshot change. To match Dunky's behavior you'd add a differ in the listener, which is what the `xstate` column already does, for a fair comparison. @@ -77,9 +77,9 @@ affected observer" cycles complete per second (higher is better): | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 100 | 653.3 K | 451.0 K | -| 1 000 | 56.5 K | 49.3 K | -| 5 000 | **14.7 K** | 1.4 K | +| 100 | 663.3 K | 455.2 K | +| 1 000 | 58.4 K | 50.0 K | +| 5 000 | **21.2 K** | 1.4 K | Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one. @@ -89,8 +89,8 @@ The whole point of the plain-object model: memory grows with your data, not with | Context width | Dunky | XState | Zag | | ------------- | -----: | -----: | -----: | -| 2 fields | 3.6 KB | 3.6 KB | 8.9 KB | -| 64 fields | 4.1 KB | 4.1 KB | 134 KB | +| 2 fields | 3.5 KB | 3.6 KB | 8.9 KB | +| 64 fields | 4.0 KB | 4.1 KB | 134 KB | Going 2 → 64 fields costs Dunky **~0.5 KB/machine**. Zag allocates one reactive cell per field, so a 64-field context grows to **134 KB/machine**. @@ -100,9 +100,9 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 1.38 | **1.18** | 5.23 | +| µs / machine | 1.35 | **1.33** | 5.52 | -XState cold-starts ~1.2× faster; Zag is ~3.8× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). +XState cold-starts as fast or slightly faster (~1.0×); Zag is ~4.1× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). ## Machines written in TS vs Rust @@ -110,13 +110,13 @@ Every machine runs on the Rust engine, but you can write it in TypeScript or in | Scenario (ops/s) | TS machine | Rust machine | | ---------------------------------- | ---------: | -----------: | -| One event | **10.5 M** | 6.4 M | -| State churn (exit + entry actions) | **9.7 M** | 6.2 M | -| Guard fallthrough, 8 candidates | **8.3 M** | 7.9 M | -| Command palette, 1,000 commands | **13.1 K** | 8.2 K | -| Command palette, 10,000 commands | **1.3 K** | 817 | +| One event | **9.8 M** | 6.6 M | +| State churn (exit + entry actions) | **9.5 M** | 6.4 M | +| Guard fallthrough, 8 candidates | **8.2 M** | 8.0 M | +| Command palette, 1,000 commands | **13.3 K** | 8.4 K | +| Command palette, 10,000 commands | **1.3 K** | 836 | -In JavaScript, a TS machine is faster: its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. In React both render alike: a 1000-row list re-renders in about 2.5 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. +In JavaScript, a TS machine is faster: its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. In React both render alike: a 1000-row list re-renders in about 2.4 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. ## Where this matters From 9cf543c0146414c86d63542a8eaa231ba1149951 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 15:03:24 +0200 Subject: [PATCH 18/28] chore(core): clippy clean for dunky-core on its own 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 --- crates/core/src/computed.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs index 00737b5..106f9aa 100644 --- a/crates/core/src/computed.rs +++ b/crates/core/src/computed.rs @@ -202,6 +202,8 @@ fn fresh(core: &Core, inner: &Inner, id: usize) -> Option(core: &Core, inner: &Inner, id: usize) -> Rc { let def = &inner.config.0.computed[id]; + // One arm without the `host` feature. + #[allow(clippy::infallible_destructuring_match)] let eval = match &def.eval { ComputedSource::Fn(f) => f, #[cfg(feature = "host")] From 134cd87e2c05f0e80e9f1ba18978e2bdeb19de56 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 15:03:25 +0200 Subject: [PATCH 19/28] feat: a Rust machine's TS types come from its Rust types The TS types of a Rust machine were written by hand (fromWasm), 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 --- .changeset/rust-engine.md | 7 +- ARCHITECTURE.md | 3 + benchmark/rust.ts | 25 +- benchmark/rust/Cargo.toml | 3 + benchmark/rust/examples/typescript.rs | 6 + benchmark/rust/src/lib.rs | 18 ++ crates/core/SPEC.md | 6 + crates/core/src/lib.rs | 6 +- crates/core/src/ts.rs | 168 ++++++++++++ crates/core/tests/derive.rs | 51 +++- crates/macros/Cargo.toml | 2 +- crates/macros/src/lib.rs | 67 ++++- crates/macros/src/ts.rs | 298 +++++++++++++++++++++ crates/wasm/README.md | 11 +- crates/wasm/SPEC.md | 8 +- crates/wasm/src/lib.rs | 75 +++++- packages/core/README.md | 18 +- packages/core/src/from-wasm.ts | 39 ++- packages/core/src/index.ts | 2 + sandbox/shared/rust/Cargo.toml | 4 + sandbox/shared/rust/examples/typescript.rs | 6 + sandbox/shared/rust/src/lib.rs | 12 + sandbox/shared/rust/src/palette.rs | 8 +- sandbox/shared/src/rust.ts | 34 ++- scripts/build-wasm.sh | 11 + tsconfig.build.json | 3 +- 26 files changed, 837 insertions(+), 54 deletions(-) create mode 100644 benchmark/rust/examples/typescript.rs create mode 100644 crates/core/src/ts.rs create mode 100644 crates/macros/src/ts.rs create mode 100644 sandbox/shared/rust/examples/typescript.rs diff --git a/.changeset/rust-engine.md b/.changeset/rust-engine.md index ba70aa8..eb0703d 100644 --- a/.changeset/rust-engine.md +++ b/.changeset/rust-engine.md @@ -21,9 +21,14 @@ import { fromWasm } from '@dunky.dev/state-machine' import init, { PaletteMachine } from './pkg/machines.js' // your wasm-bindgen output await init() -const palette = fromWasm(new PaletteMachine(commands)) +// Its types come from the Rust types: no generics to write, nothing to keep in sync. +const palette = fromWasm(new PaletteMachine(commands)) ``` +A Rust machine's TypeScript types are generated from its Rust types (the derives +plus `#[derive(TsType)]` on your data types): `PaletteMachine::typescript(&config)` +returns them for the wasm-bindgen `.d.ts`, and `fromWasm` infers them from the class. + The `computed` option of `fromWasm` maps a raw value once per change — for example, indices that crossed the boundary back onto your own objects. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index db764bd..03ff289 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -223,6 +223,8 @@ crates/core-wasm (inlined in the package) your wasm module - Typed Rust (`#[derive(State, Event, Context)]`, a `Config` built once), exported to JS with `dunky_wasm::export_machine!` in your own wasm module, and wrapped with `fromWasm`. +- Its **TS types** come from its Rust types: the build generates them onto the exported + class, and `fromWasm` infers them, so nobody writes them twice. - The engine runs everything, user code included, inside wasm. The facade keeps a JS **mirror** of the context and re-reads only the fields each notify names; a computed value crosses only when it changed. @@ -287,6 +289,7 @@ JSI (in progress); until it lands, the package does not run on React Native. | **whole transition** | A leaving transition the host runs in one call: effect cleanups, exit actions, actions, the switch and its notification, entry actions, effects. Only where JS cannot see the switch. | | **command** | What a timer is to the engine: start or cancel, with an id. The host runs it on its clock and reports a due timer. | | **change mask** | A bit per context field (64 at most), plus the state: what a write or a step changed. | +| **TS types** | A Rust machine's TypeScript types, generated from its Rust types (`TsType`) onto its exported class; `fromWasm` infers them. | ## Versioning diff --git a/benchmark/rust.ts b/benchmark/rust.ts index 96aa338..fcd4d30 100644 --- a/benchmark/rust.ts +++ b/benchmark/rust.ts @@ -45,12 +45,7 @@ type Palette = Machine< /** The command palette; `results` crosses as indices, mapped onto the caller's objects. */ export function createPalette(commands: Command[]): Palette { - return fromWasm< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed - >(new PaletteMachine(commands), { + return fromWasm(new PaletteMachine(commands), { computed: { results: { from: 'resultIndices', @@ -65,32 +60,26 @@ export function createPaletteObjects(commands: Command[]): Palette { return fromWasm(new PaletteMachine(commands)) } -export type CellContext = { value: number; other: number } -export function createCell(): Machine<'idle', CellContext, { type: 'hit' | 'miss' }> { +// Each machine's types come from its Rust types (generated onto the class by the build). +export function createCell() { return fromWasm(new CellMachine()) } -export function createPingPong(): Machine<'ping' | 'pong', object, { type: 'go' }> { +export function createPingPong() { return fromWasm(new PingPongMachine()) } -export function createGuards(k: number): Machine<'idle', { pick: number }, { type: 'go' }> { +export function createGuards(k: number) { return fromWasm(new GuardsMachine(k)) } /** The rendering bench's shared list: one highlighted index. */ -export function createList(): Machine< - 'idle', - { highlighted: number }, - { type: 'move'; to: number } -> { +export function createList() { return fromWasm(new ListMachine()) } /** The rendering bench's per-row machine: one boolean. */ -export function createHighlight( - on: boolean, -): Machine<'idle', { on: boolean }, { type: 'set'; on: boolean }> { +export function createHighlight(on: boolean) { return fromWasm(new HighlightMachine(on)) } diff --git a/benchmark/rust/Cargo.toml b/benchmark/rust/Cargo.toml index 8c985ba..d1e1971 100644 --- a/benchmark/rust/Cargo.toml +++ b/benchmark/rust/Cargo.toml @@ -15,3 +15,6 @@ dunky-wasm = { workspace = true } dunky-sandbox = { workspace = true } serde = { workspace = true } wasm-bindgen = { workspace = true } + +[[example]] +name = "typescript" diff --git a/benchmark/rust/examples/typescript.rs b/benchmark/rust/examples/typescript.rs new file mode 100644 index 0000000..87e6736 --- /dev/null +++ b/benchmark/rust/examples/typescript.rs @@ -0,0 +1,6 @@ +//! Prints the TS types of the exported classes; `pnpm build:wasm` appends them to the +//! module's `.d.ts`, where `fromWasm` reads them. + +fn main() { + print!("{}", dunky_benchmark::typescript()); +} diff --git a/benchmark/rust/src/lib.rs b/benchmark/rust/src/lib.rs index 609cf94..54e3ffc 100644 --- a/benchmark/rust/src/lib.rs +++ b/benchmark/rust/src/lib.rs @@ -59,6 +59,9 @@ dunky_wasm::export_machine! { computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option, + } + // Only the JS side's `results` mapping reads it: not part of the machine's types. + internal { palette::RESULT_INDICES => Vec, } } @@ -99,3 +102,18 @@ dunky_wasm::export_machine! { } computed {} } + +/// The classes' TS types (`examples/typescript.rs`, run by `pnpm build:wasm`). The guards +/// config only sizes its candidate list, so any `k` gives the same types. +#[cfg(not(target_arch = "wasm32"))] +pub fn typescript() -> String { + [ + PALETTE.with(PaletteMachine::typescript), + CELL.with(CellMachine::typescript), + PINGPONG.with(PingPongMachine::typescript), + GuardsMachine::typescript(&guards::config(1)), + LIST.with(ListMachine::typescript), + HIGHLIGHT.with(HighlightMachine::typescript), + ] + .concat() +} diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md index 0452efb..604bfd9 100644 --- a/crates/core/SPEC.md +++ b/crates/core/SPEC.md @@ -64,6 +64,12 @@ reporting; and the binding API (cargo feature `host`). equality (`PartialEq` by default). - With the `serde` feature, a context field serializes by index and an event rebuilds from its kind and its payload: what a binding needs to cross into another runtime. +- **TS types.** A machine type knows its TypeScript types (`TsType`), so a binding can + generate them instead of anyone writing them twice. The derives write them for the + states, for the events (with `#[event(deserialize)]`) and for the context (with + `#[context(serialize)]`); the data types they hold derive `TsType`. Names and shapes + follow the serde attributes the values cross with: `None` is `null`, a field serde + skips when it is `None` is optional, every number is a `number`. ## The behavior contract diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 6ca3446..7d1833b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -28,6 +28,7 @@ mod selection; mod store; mod timers; mod traits; +mod ts; pub mod testing; @@ -51,9 +52,10 @@ pub use timers::{Command, TimerId}; pub use traits::{Context, EventEnum, Field, StateEnum, Types}; #[cfg(feature = "serde")] pub use traits::{DeserializeEvent, SerializeFields}; +pub use ts::TsType; #[cfg(feature = "derive")] -pub use dunky_macros::{Context, Event, State}; +pub use dunky_macros::{Context, Event, State, TsType}; /// The `machine.init` marker name: what a boot effect or a data-reaction sees as its /// event type (`ActionParams::event()` returns `None` for it). @@ -63,4 +65,6 @@ pub const MACHINE_INIT: &str = "machine.init"; pub mod __private { #[cfg(feature = "serde")] pub use serde; + + pub use crate::ts::{key as ts_key, literal as ts_literal, object as ts_object}; } diff --git a/crates/core/src/ts.rs b/crates/core/src/ts.rs new file mode 100644 index 0000000..b80131f --- /dev/null +++ b/crates/core/src/ts.rs @@ -0,0 +1,168 @@ +//! TypeScript types of the values a machine shows JS, so a binding can generate a Rust +//! machine's TS types from its Rust ones. The mapping follows how `dunky-wasm` moves +//! values through serde: `None` is `null`, sequences are arrays, maps are `Map`s, and +//! every number is a `number`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +/// The TypeScript type of a value as JS sees it. `#[derive(TsType)]` implements it for +/// your own data types; `#[derive(State, Event, Context)]` implement it for the machine's. +pub trait TsType { + /// The type, written inline: `string`, `{ id: string; hint?: string }`, `number[]`. + fn ts() -> String; +} + +macro_rules! literal { + ($ts:literal: $($ty:ty),*) => { + $(impl TsType for $ty { + fn ts() -> String { + $ts.into() + } + })* + }; +} + +literal!("number": u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64); +literal!("string": String, str, char); +literal!("boolean": bool); +literal!("null": ()); + +macro_rules! same_as_inner { + ($($ty:ident),*) => { + $(impl TsType for $ty { + fn ts() -> String { + T::ts() + } + })* + }; +} + +same_as_inner!(Box, Rc, Arc); + +impl TsType for &T { + fn ts() -> String { + T::ts() + } +} + +impl TsType for Option { + fn ts() -> String { + format!("{} | null", T::ts()) + } +} + +macro_rules! sequence { + ($($ty:ident),*) => { + $(impl TsType for $ty { + fn ts() -> String { + array(&T::ts()) + } + })* + }; +} + +sequence!(Vec, VecDeque, HashSet, BTreeSet); + +impl TsType for [T] { + fn ts() -> String { + array(&T::ts()) + } +} + +impl TsType for [T; N] { + fn ts() -> String { + array(&T::ts()) + } +} + +impl TsType for HashMap { + fn ts() -> String { + format!("Map<{}, {}>", K::ts(), V::ts()) + } +} + +impl TsType for BTreeMap { + fn ts() -> String { + format!("Map<{}, {}>", K::ts(), V::ts()) + } +} + +macro_rules! tuple { + ($($name:ident),+) => { + impl<$($name: TsType),+> TsType for ($($name,)+) { + fn ts() -> String { + let items: &[String] = &[$($name::ts()),+]; + format!("[{}]", items.join(", ")) + } + } + }; +} + +tuple!(A); +tuple!(A, B); +tuple!(A, B, C); +tuple!(A, B, C, D); + +/// `T[]`, or `Array` when `T` is a union or an object. +fn array(item: &str) -> String { + if item.contains(' ') { + format!("Array<{item}>") + } else { + format!("{item}[]") + } +} + +/// An object key: as is when it is an identifier, else quoted. +#[doc(hidden)] +pub fn key(name: &str) -> String { + let mut chars = name.chars(); + let identifier = chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$'); + if identifier { + name.to_owned() + } else { + literal(name) + } +} + +/// A string literal type: `'name'`. +#[doc(hidden)] +pub fn literal(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + +/// An object type from its members (`key: type` or `key?: type`). +#[doc(hidden)] +pub fn object(members: &[String]) -> String { + if members.is_empty() { + "{}".into() + } else { + format!("{{ {} }}", members.join("; ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_values_the_way_they_reach_js() { + assert_eq!(>::ts(), "string | null"); + assert_eq!(>::ts(), "number[]"); + assert_eq!(>>::ts(), "Array"); + assert_eq!(>::ts(), "Map"); + assert_eq!(<(u8, String)>::ts(), "[number, string]"); + } + + #[test] + fn quotes_keys_and_literals_only_when_needed() { + assert_eq!(key("activeId"), "activeId"); + assert_eq!(key("query.set"), "'query.set'"); + assert_eq!(literal("it's"), "'it\\'s'"); + assert_eq!(object(&[]), "{}"); + } +} diff --git a/crates/core/tests/derive.rs b/crates/core/tests/derive.rs index f5e83fc..753c90c 100644 --- a/crates/core/tests/derive.rs +++ b/crates/core/tests/derive.rs @@ -1,8 +1,8 @@ //! Rust-only: the derive macros name events exactly like serde, so the JS side can send //! `{ type, ... }` objects that both decoders (serde and `DeserializeEvent`) accept. -use dunky_core::{DeserializeEvent, Event, EventEnum}; -use serde::Deserialize; +use dunky_core::{Context, DeserializeEvent, Event, EventEnum, State, TsType}; +use serde::{Deserialize, Serialize}; #[derive(Event, Clone, Debug, PartialEq, Deserialize)] #[event(deserialize)] @@ -39,3 +39,50 @@ fn payload_decoding_follows_the_variant_field_renames() { ); assert_eq!(event.unwrap(), Picker::ItemPick { item_id: 3 }); } + +// Only their TS types are read. +#[allow(dead_code)] +#[derive(TsType, Serialize, Clone, PartialEq, Debug)] +struct Item { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option, + note: Option, +} + +#[allow(dead_code)] +#[derive(TsType, Serialize, Clone, Copy, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +enum Direction { + Up, + Down, +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + Idle, + HalfOpen, +} + +#[derive(Context, Clone, PartialEq, Debug)] +#[context(serialize)] +struct Ctx { + items: Vec, + active_index: u32, + direction: Option, +} + +#[test] +fn ts_types_follow_the_js_names_and_values() { + assert_eq!(Mode::ts(), "'idle' | 'halfOpen'"); + assert_eq!( + Picker::ts(), + "{ type: 'query_set'; query: string } | { type: 'item_pick'; itemId: number } \ + | { type: 'picker.close' }" + ); + assert_eq!( + Ctx::ts(), + "{ items: Array<{ id: string; hint?: string; note: string | null }>; \ + activeIndex: number; direction: 'up' | 'down' | null }" + ); +} diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 3139212..3e0c26c 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dunky-macros" -description = "Derive macros for dunky-core: State, Event, Context." +description = "Derive macros for dunky-core: State, Event, Context, TsType." version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 08b42cf..6e2ea93 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -1,5 +1,7 @@ //! Derive macros for `dunky-core`. Use them through the re-exports in `dunky_core`. +mod ts; + use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -41,6 +43,18 @@ pub fn derive_context(input: TokenStream) -> TokenStream { .into() } +/// `#[derive(TsType)]` on a data type a machine shows JS (a context field, an event +/// payload, a computed value): its TypeScript type, from the serde attributes it crosses +/// with. Supports structs with named fields, newtypes, unit enums (string unions), and +/// internally tagged enums (`#[serde(tag = "...")]`). +#[proc_macro_derive(TsType)] +pub fn derive_ts(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + ts::expand(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + fn camel(ident: &Ident) -> String { let s = ident.to_string(); let s = s.strip_prefix("r#").unwrap_or(&s); @@ -99,7 +113,7 @@ fn name_attr(attrs: &[Attribute], attr: &str, serde_too: bool) -> syn::Result Option { +pub(crate) fn serde_case(variant: &str, rule: &str) -> Option { let snake = || { let mut out = String::new(); for (i, ch) in variant.char_indices() { @@ -191,8 +205,15 @@ fn expand_state(input: DeriveInput) -> syn::Result { variants.push(&v.ident); } let indices: Vec = (0..variants.len()).collect(); + let literals: Vec = names + .iter() + .map(|n| quote!(::dunky_core::__private::ts_literal(#n))) + .collect(); + let ts_impl = ts::impl_ts(&input, ts::union(&literals)); let (impl_g, ty_g, where_g) = input.generics.split_for_impl(); Ok(quote! { + #ts_impl + impl #impl_g ::dunky_core::StateEnum for #name #ty_g #where_g { const NAMES: &'static [&'static str] = &[#(#names),*]; fn index(self) -> usize { @@ -285,6 +306,28 @@ fn expand_event(input: DeriveInput) -> syn::Result { }); } let indices: Vec = (0..variants.len()).collect(); + let ts_impl = if deserialize { + let mut types = Vec::new(); + for (v, kind_name) in data.variants.iter().zip(&names) { + let fields_rule = + serde_str(&v.attrs, "rename_all")?.or_else(|| rename_all_fields.clone()); + let mut members = vec![ts::tag_member("type", kind_name)]; + match &v.fields { + Fields::Unit => {} + Fields::Named(fields) => members.extend(ts::members( + &fields.named, + fields_rule.as_deref(), + ts::Direction::FromJs, + )?), + // JS cannot send it: no field names to read. + Fields::Unnamed(_) => continue, + } + types.push(ts::object(&members)); + } + ts::impl_ts(&input, ts::union(&types)) + } else { + quote! {} + }; let deserialize_impl = if deserialize { quote! { impl ::dunky_core::DeserializeEvent for #name { @@ -334,6 +377,8 @@ fn expand_event(input: DeriveInput) -> syn::Result { } #deserialize_impl + + #ts_impl }) } @@ -389,6 +434,24 @@ fn expand_context(input: DeriveInput) -> syn::Result { let indices: Vec = (0..idents.len()).collect(); let bits: Vec = indices.iter().map(|i| 1u64 << i).collect(); + let ts_impl = if serialize { + let members: Vec = js_names + .iter() + .zip(&tys) + .map(|(js, ty)| { + quote! { + ::std::format!( + "{}: {}", + ::dunky_core::__private::ts_key(#js), + <#ty as ::dunky_core::TsType>::ts(), + ) + } + }) + .collect(); + ts::impl_ts(&input, ts::object(&members)) + } else { + quote! {} + }; let serialize_impl = if serialize { quote! { impl ::dunky_core::SerializeFields for #name { @@ -479,5 +542,7 @@ fn expand_context(input: DeriveInput) -> syn::Result { } #serialize_impl + + #ts_impl }) } diff --git a/crates/macros/src/ts.rs b/crates/macros/src/ts.rs new file mode 100644 index 0000000..72acf94 --- /dev/null +++ b/crates/macros/src/ts.rs @@ -0,0 +1,298 @@ +//! TypeScript types for the derives: `#[derive(TsType)]` on data types, and the +//! `TsType` impls `State`, `Event` and `Context` emit. Names follow the serde attributes +//! the value crosses with, so the TS type matches the JS value. + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, Ident, Lit, + PathArguments, Type, +}; + +use crate::serde_case; + +/// The serde attributes that shape a value's JS form. +#[derive(Default)] +pub(crate) struct Serde { + pub(crate) rename: Option, + pub(crate) rename_all: Option, + pub(crate) rename_all_fields: Option, + pub(crate) tag: Option, + skip: bool, + skip_serializing: bool, + skip_deserializing: bool, + skip_serializing_if: Option, + default: bool, + transparent: bool, +} + +pub(crate) fn serde_attrs(attrs: &[Attribute]) -> syn::Result { + let mut out = Serde::default(); + for a in attrs.iter().filter(|a| a.path().is_ident("serde")) { + a.parse_nested_meta(|meta| { + let name = meta + .path + .get_ident() + .map(Ident::to_string) + .unwrap_or_default(); + let value = if meta.input.peek(syn::Token![=]) { + match meta.value()?.parse::()? { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Some(s.value()), + _ => None, + } + } else if meta.input.peek(syn::token::Paren) { + if matches!(name.as_str(), "rename" | "rename_all") { + return Err( + meta.error("TsType: split serialize/deserialize names are not supported") + ); + } + let _content; + syn::parenthesized!(_content in meta.input); + None + } else { + None + }; + match name.as_str() { + "rename" => out.rename = value, + "rename_all" => out.rename_all = value, + "rename_all_fields" => out.rename_all_fields = value, + "tag" => out.tag = value, + "skip" => out.skip = true, + "skip_serializing" => out.skip_serializing = true, + "skip_deserializing" => out.skip_deserializing = true, + "skip_serializing_if" => out.skip_serializing_if = value, + "default" => out.default = true, + "transparent" => out.transparent = true, + "flatten" | "untagged" | "content" => { + return Err(meta.error(format!("TsType: `{name}` is not supported"))); + } + _ => {} + } + Ok(()) + })?; + } + Ok(out) +} + +/// Which way a value crosses: shown to JS (serialized) or sent from JS (deserialized). +#[derive(Clone, Copy)] +pub(crate) enum Direction { + ToJs, + FromJs, +} + +/// serde's `rename_all` rules for a (snake_case) field name, like serde applies them. +fn field_case(field: &str, rule: &str) -> Option { + let pascal = || { + field + .split('_') + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect::() + }; + Some(match rule { + "lowercase" | "snake_case" => field.to_owned(), + "UPPERCASE" | "SCREAMING_SNAKE_CASE" => field.to_ascii_uppercase(), + "PascalCase" => pascal(), + "camelCase" => { + let pascal = pascal(); + let mut chars = pascal.chars(); + chars + .next() + .map(|c| c.to_ascii_lowercase().to_string() + chars.as_str()) + .unwrap_or_default() + } + "kebab-case" => field.replace('_', "-"), + "SCREAMING-KEBAB-CASE" => field.replace('_', "-").to_ascii_uppercase(), + _ => return None, + }) +} + +fn unknown_rule(span: &impl quote::ToTokens, rule: &str) -> syn::Error { + syn::Error::new_spanned(span, format!("unknown rename_all rule \"{rule}\"")) +} + +/// The inner type of `Option`, read from the syntax. +fn option_inner(ty: &Type) -> Option<&Type> { + let Type::Path(path) = ty else { return None }; + let last = path.path.segments.last()?; + if last.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + match args.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + +/// The TS members of named fields (`key: type` or `key?: type`), as expressions that +/// build them. `rename_all` is the rule the fields' container applies. +pub(crate) fn members<'a>( + fields: impl IntoIterator, + rename_all: Option<&str>, + direction: Direction, +) -> syn::Result> { + let mut out = Vec::new(); + for f in fields { + let attrs = serde_attrs(&f.attrs)?; + let skipped = attrs.skip + || match direction { + Direction::ToJs => attrs.skip_serializing, + Direction::FromJs => attrs.skip_deserializing, + }; + if skipped { + continue; + } + let ident = f.ident.as_ref().expect("named field").to_string(); + let ident = ident.strip_prefix("r#").unwrap_or(&ident); + let name = match (&attrs.rename, rename_all) { + (Some(name), _) => name.clone(), + (None, Some(rule)) => field_case(ident, rule).ok_or_else(|| unknown_rule(f, rule))?, + (None, None) => ident.to_owned(), + }; + let none_skipped = attrs.skip_serializing_if.as_deref() == Some("Option::is_none"); + let (optional, ty) = match direction { + // `None` never crosses as `null` when serde skips it. + Direction::ToJs => match option_inner(&f.ty) { + Some(inner) if none_skipped => (true, inner), + _ => (attrs.skip_serializing_if.is_some(), &f.ty), + }, + // serde reads a missing `Option` field as `None`. + Direction::FromJs => (attrs.default || option_inner(&f.ty).is_some(), &f.ty), + }; + let mark = if optional { "?" } else { "" }; + out.push(quote! { + ::std::format!( + "{}{}: {}", + ::dunky_core::__private::ts_key(#name), + #mark, + <#ty as ::dunky_core::TsType>::ts(), + ) + }); + } + Ok(out) +} + +/// An object type from member expressions. +pub(crate) fn object(members: &[TokenStream2]) -> TokenStream2 { + quote!(::dunky_core::__private::ts_object(&[#(#members),*])) +} + +/// The member that names a variant of a tagged union: `type: 'open'`. +pub(crate) fn tag_member(tag: &str, name: &str) -> TokenStream2 { + quote! { + ::std::format!( + "{}: {}", + ::dunky_core::__private::ts_key(#tag), + ::dunky_core::__private::ts_literal(#name), + ) + } +} + +/// Join type expressions into a union (`never` when there are none). +pub(crate) fn union(types: &[TokenStream2]) -> TokenStream2 { + if types.is_empty() { + return quote!(::std::string::String::from("never")); + } + quote!([#(#types),*].join(" | ")) +} + +/// `impl TsType` with the body `ts`, bounding every type parameter by `TsType`. +pub(crate) fn impl_ts(input: &DeriveInput, ts: TokenStream2) -> TokenStream2 { + let name = &input.ident; + let mut generics = input.generics.clone(); + for param in generics.type_params_mut() { + param.bounds.push(syn::parse_quote!(::dunky_core::TsType)); + } + let (impl_g, ty_g, where_g) = generics.split_for_impl(); + quote! { + impl #impl_g ::dunky_core::TsType for #name #ty_g #where_g { + fn ts() -> ::std::string::String { + #ts + } + } + } +} + +/// `#[derive(TsType)]`: the value as JS reads it (serialized with serde). +pub(crate) fn expand(input: DeriveInput) -> syn::Result { + let container = serde_attrs(&input.attrs)?; + let ts = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) if !container.transparent => object(&members( + &fields.named, + container.rename_all.as_deref(), + Direction::ToJs, + )?), + Fields::Named(fields) if fields.named.len() == 1 => { + let ty = &fields.named[0].ty; + quote!(<#ty as ::dunky_core::TsType>::ts()) + } + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let ty = &fields.unnamed[0].ty; + quote!(<#ty as ::dunky_core::TsType>::ts()) + } + Fields::Unit => quote!(::std::string::String::from("null")), + _ => { + return Err(syn::Error::new_spanned( + &input.ident, + "TsType: use named fields, or one field (a newtype)", + )); + } + }, + Data::Enum(data) => { + let mut variants = Vec::new(); + for v in &data.variants { + let attrs = serde_attrs(&v.attrs)?; + if attrs.skip || attrs.skip_serializing { + continue; + } + let ident = v.ident.to_string(); + let name = match (&attrs.rename, &container.rename_all) { + (Some(name), _) => name.clone(), + (None, Some(rule)) => { + serde_case(&ident, rule).ok_or_else(|| unknown_rule(&v.ident, rule))? + } + (None, None) => ident, + }; + let fields_rule = attrs + .rename_all + .as_deref() + .or(container.rename_all_fields.as_deref()); + variants.push(match (&container.tag, &v.fields) { + (None, Fields::Unit) => quote!(::dunky_core::__private::ts_literal(#name)), + (Some(tag), Fields::Unit) => object(&[tag_member(tag, &name)]), + (Some(tag), Fields::Named(fields)) => { + let mut all = vec![tag_member(tag, &name)]; + all.extend(members(&fields.named, fields_rule, Direction::ToJs)?); + object(&all) + } + _ => { + return Err(syn::Error::new_spanned( + v, + "TsType: an enum with data needs #[serde(tag = \"...\")] and named fields", + )); + } + }); + } + union(&variants) + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "TsType: unions are not supported", + )); + } + }; + Ok(impl_ts(&input, ts)) +} diff --git a/crates/wasm/README.md b/crates/wasm/README.md index 45b8468..b40af18 100644 --- a/crates/wasm/README.md +++ b/crates/wasm/README.md @@ -19,14 +19,19 @@ dunky_wasm::export_machine! { .map(|c| CONFIG.with(|config| Machine::with_context(config, palette::context(c)))) } computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } + // Optional: served too, but left out of the machine's TS types. + internal { palette::RESULT_INDICES => Vec } } ``` -Build it, then wrap it: +Build it, add its TS types, then wrap it: ```bash cargo build --target wasm32-unknown-unknown --release wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg target/wasm32-unknown-unknown/release/my_machines.wasm +# Natively, PaletteMachine::typescript(&config) returns the class's TS types, generated +# from the Rust types: print them from a small binary or example, onto the .d.ts. +cargo run --example typescript >> pkg/my_machines.d.ts ``` ```ts @@ -34,9 +39,13 @@ import { fromWasm } from '@dunky.dev/state-machine' import init, { PaletteMachine } from './pkg/my_machines.js' await init() +// Machine<'closed' | 'open', { commands: ...; query: string; ... }, ...>: from Rust. const palette = fromWasm(new PaletteMachine(commands)) ``` +The data types the machine shows JS derive `TsType` (`#[derive(TsType)]`), next to +their serde derives. + The crate using the macro also depends on `wasm-bindgen` (the version this crate pins), and its event type derives `#[event(deserialize)]`. The `runtime` feature is the engine for machines written in TS; `@dunky.dev/state-machine` ships it. diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md index d271b19..932e1ff 100644 --- a/crates/wasm/SPEC.md +++ b/crates/wasm/SPEC.md @@ -133,7 +133,8 @@ machines; the conversion helpers between JS values and Rust values. ## The export macro — Rust machines - The macro takes the class name, the machine type, a constructor body, and the derived - values the class serves, each with its key and its type. A constructor body that + values the class serves, each with its key and its type; an optional `internal` block + lists derived values it serves but leaves out of its types. A constructor body that returns an error throws it from the JS constructor; bad arguments read as `[machine] bad argument: ...`. - The class implements the protocol: attach to a host (once), dispatch by kind, start, @@ -146,6 +147,11 @@ machines; the conversion helpers between JS values and Rust values. - An event kind without data is built from the number alone; any other kind is read from the JS event's fields. The event type opts in to that with `#[event(deserialize)]`. - The crate using the macro depends on `wasm-bindgen` at the version this crate pins. +- **Types.** Natively, the class reports its TS types (`typescript(&config)`), generated + from the machine's Rust types: the states, the context, the events, and the derived + values outside `internal`. The build appends them to the module's `.d.ts` as a + `__types` member of the class, which exists only in the types; `fromWasm` infers the + machine's types from it. A Rust type change changes the TS types on the next build. ## Edge cases that carry design meaning diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index c495568..c7d055b 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -12,6 +12,10 @@ //! `fire(id)`), and a status code back from every call (see [`bridge`]). //! //! The crate using the macro must also depend on `wasm-bindgen`. +//! +//! A class also knows its TS types (`typescript`, natively): they come from the machine's +//! Rust types (`TsType`), and `pnpm build:wasm` adds them to the module's `.d.ts`, where +//! `fromWasm` reads them. use std::cell::RefCell; use std::rc::Rc; @@ -34,7 +38,49 @@ pub use bridge::{Bridge, ENGINE_FAILED, FAILED, HostObject, OK}; #[doc(hidden)] pub mod __private { - pub use dunky_core::ComputedKey; + pub use dunky_core::{ComputedKey, Config, TsType}; + + use dunky_core::__private::{ts_key, ts_object}; + use dunky_core::{Context, Types}; + + /// The declaration that gives exported class `class` its machine's types: a + /// `__types` member, only in the types, that `fromWasm` reads. `computed` holds the + /// computed values of the types by id, with their TS types. + pub fn class_types( + class: &str, + config: &Config, + computed: &[(usize, String)], + ) -> String + where + T::State: TsType, + T::Event: TsType, + T::Context: TsType, + { + let names: Vec<&str> = config.computed_names().collect(); + let computed: Vec = computed + .iter() + .map(|(id, ts)| format!("{}: {ts}", ts_key(names[*id]))) + .collect(); + // The facade builds the context from the fields: none gives an empty object. + let empty = || String::from("Record"); + let context = if ::FIELDS.is_empty() { + empty() + } else { + ::ts() + }; + let computed = if computed.is_empty() { + empty() + } else { + ts_object(&computed) + }; + format!( + "\nexport interface {class} {{\n /** Generated from the Rust machine's types; `fromWasm` reads them. */\n readonly __types?: {{\n state: {};\n context: {};\n event: {};\n computed: {};\n }};\n}}\n", + ::ts(), + context, + ::ts(), + computed, + ) + } } /// What an [`export_machine!`] constructor body may evaluate to: a machine, or a @@ -284,8 +330,13 @@ where /// dunky_wasm::from_js(commands).map(|c| Machine::new(&palette::config(c))) /// } /// computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +/// // Optional: served too, but left out of the machine's TS types. +/// internal { palette::RESULT_INDICES => Vec } /// } /// ``` +/// +/// Natively, the class also has `typescript(&config)`: its TS types, from the machine's +/// Rust types, for `pnpm build:wasm` to add to the module's `.d.ts`. #[macro_export] macro_rules! export_machine { ( @@ -293,6 +344,7 @@ macro_rules! export_machine { $vis:vis struct $name:ident($types:ty); new($($arg:ident : $argty:ty),* $(,)?) $make:block computed { $($key:expr => $cty:ty),* $(,)? } + $(internal { $($ikey:expr => $ity:ty),* $(,)? })? ) => { $(#[$meta])* #[::wasm_bindgen::prelude::wasm_bindgen] @@ -348,11 +400,32 @@ macro_rules! export_machine { return self.inner.computed(key); } )* + $($( + let key: $crate::__private::ComputedKey<$ity> = $ikey; + if id as usize == key.id() { + return self.inner.computed(key); + } + )*)? ::wasm_bindgen::JsValue::UNDEFINED } pub fn meta(&self) -> ::wasm_bindgen::JsValue { self.inner.meta() } } + + #[cfg(not(target_arch = "wasm32"))] + impl $name { + /// This class's TS types, from the machine's Rust types. + pub fn typescript(config: &$crate::__private::Config<$types>) -> ::std::string::String { + $crate::__private::class_types::<$types>( + ::core::stringify!($name), + config, + &[$({ + let key: $crate::__private::ComputedKey<$cty> = $key; + (key.id(), <$cty as $crate::__private::TsType>::ts()) + }),*], + ) + } + } }; } diff --git a/packages/core/README.md b/packages/core/README.md index 499b8c8..c8f14b8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -929,23 +929,31 @@ dunky_wasm::export_machine! { dunky_wasm::from_js(commands).map(|c| Machine::with_context(&CONFIG.with(Clone::clone), palette::context(c))) } computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } + // Served too, but left out of the machine's TS types. + internal { palette::RESULT_INDICES => Vec } } ``` -Build it with `wasm-bindgen`, then wrap an instance with `fromWasm`. The result is a -`Machine` like any other: the connector, `useMachine` and every target consume it -unchanged, with the same notifications as a TS machine. +Build it with `wasm-bindgen`, add its TS types (natively, `PaletteMachine::typescript` +generates them from the Rust types, for the `.d.ts`), then wrap an instance with +`fromWasm`. The result is a `Machine` like any other: the connector, `useMachine` and +every target consume it unchanged, with the same notifications as a TS machine. ```ts import { fromWasm } from '@dunky.dev/state-machine' import init, { PaletteMachine } from './pkg/machines.js' // your wasm-bindgen output await init() -const palette = fromWasm( +// The types come from the class: generated from the Rust types, nothing to write. +const palette = fromWasm( new PaletteMachine(commands), // Optional: rebuild a computed value from another one, e.g. indices that crossed // the boundary back onto your own objects. Runs only when the value changed. - { computed: { results: { from: 'resultIndices', map: i => i.map(n => commands[n]) } } }, + { + computed: { + results: { from: 'resultIndices', map: raw => Array.from(raw as number[], i => commands[i]) }, + }, + }, ) ``` diff --git a/packages/core/src/from-wasm.ts b/packages/core/src/from-wasm.ts index 6e5ee83..d1844e0 100644 --- a/packages/core/src/from-wasm.ts +++ b/packages/core/src/from-wasm.ts @@ -10,6 +10,22 @@ export interface WasmMachine extends Engine { meta: () => WasmMachineMeta } +/** + * A Rust machine's types. `pnpm build:wasm` generates them from the machine's Rust types + * onto its exported class, as a `__types` member that exists only in the types. + */ +export interface MachineTypes { + state: string + context: object + event: { type: string } + computed: object +} + +/** The types generated onto an exported class; loose ones when it has none. */ +export type TypesOf = Handle extends { readonly __types?: infer Types extends MachineTypes } + ? Types + : MachineTypes + /** The names a Rust machine type reports, in its numbering. */ export interface WasmMachineMeta { states: string[] @@ -129,8 +145,19 @@ class RustMachine< /** * Wrap a Rust machine (an instance of a class exported with * `dunky_wasm::export_machine!`) in the `Machine` interface, so the connector and - * every target consume it like a TS-authored one. + * every target consume it like a TS-authored one. Its types are the class's, generated + * from the machine's Rust types. */ +export function fromWasm( + handle: Handle, + options?: FromWasmOptions['computed']>, +): Machine< + TypesOf['state'], + TypesOf['context'], + TypesOf['event'], + TypesOf['computed'] +> +/** For a class built without generated types: name them. */ export function fromWasm< State extends string, Context extends object, @@ -138,9 +165,13 @@ export function fromWasm< Computed = Record, >( handle: WasmMachine, - options: FromWasmOptions = {}, -): Machine { - return new RustMachine( + options?: FromWasmOptions, +): Machine +export function fromWasm( + handle: WasmMachine, + options: FromWasmOptions> = {}, +): Machine> { + return new RustMachine>( shapeOf(handle), handle, (options.computed ?? {}) as Record | undefined>, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4496859..1d091e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,8 @@ export { fromWasm, type ComputedMapping, type FromWasmOptions, + type MachineTypes, + type TypesOf, type WasmMachine, type WasmMachineMeta, } from './from-wasm' diff --git a/sandbox/shared/rust/Cargo.toml b/sandbox/shared/rust/Cargo.toml index fc6692a..f693385 100644 --- a/sandbox/shared/rust/Cargo.toml +++ b/sandbox/shared/rust/Cargo.toml @@ -22,3 +22,7 @@ wasm-bindgen = { workspace = true, optional = true } [dev-dependencies] serde_json = { workspace = true } + +[[example]] +name = "typescript" +required-features = ["wasm"] diff --git a/sandbox/shared/rust/examples/typescript.rs b/sandbox/shared/rust/examples/typescript.rs new file mode 100644 index 0000000..303b90b --- /dev/null +++ b/sandbox/shared/rust/examples/typescript.rs @@ -0,0 +1,6 @@ +//! Prints the TS types of the exported classes; `pnpm build:wasm` appends them to the +//! module's `.d.ts`, where `fromWasm` reads them. + +fn main() { + print!("{}", dunky_sandbox::typescript()); +} diff --git a/sandbox/shared/rust/src/lib.rs b/sandbox/shared/rust/src/lib.rs index 62bedfb..3f08ac9 100644 --- a/sandbox/shared/rust/src/lib.rs +++ b/sandbox/shared/rust/src/lib.rs @@ -10,6 +10,9 @@ pub mod dialog; pub mod palette; +#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))] +pub use exports::typescript; + #[cfg(feature = "wasm")] mod exports { use dunky_core::{Config, Machine}; @@ -34,6 +37,9 @@ mod exports { computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option, + } + // Only the JS side's `results` mapping reads it: not part of the machine's types. + internal { palette::RESULT_INDICES => Vec, } } @@ -46,4 +52,10 @@ mod exports { } computed {} } + + /// The classes' TS types (`examples/typescript.rs`, run by `pnpm build:wasm`). + #[cfg(not(target_arch = "wasm32"))] + pub fn typescript() -> String { + PALETTE.with(PaletteMachine::typescript) + &DIALOG.with(DialogMachine::typescript) + } } diff --git a/sandbox/shared/rust/src/palette.rs b/sandbox/shared/rust/src/palette.rs index cb06d04..cb6a8e5 100644 --- a/sandbox/shared/rust/src/palette.rs +++ b/sandbox/shared/rust/src/palette.rs @@ -4,10 +4,10 @@ //! wraparound, clamp) lives in context + computed, driven by logical events. A renderer //! never computes an index: it sends `move` / `query.set` and reads `results` / `activeId`. -use dunky_core::{ComputedKey, Config, Context, Event, State, Types}; +use dunky_core::{ComputedKey, Config, Context, Event, State, TsType, Types}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(TsType, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Command { pub id: String, pub label: String, @@ -18,7 +18,7 @@ pub struct Command { } /// Stamped on every execute; the nonce makes each execution a distinct value. -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(TsType, Clone, Debug, PartialEq, Serialize)] pub struct Executed { pub id: String, pub nonce: u32, @@ -30,7 +30,7 @@ pub enum PaletteState { Open, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[derive(TsType, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MoveTo { Up, diff --git a/sandbox/shared/src/rust.ts b/sandbox/shared/src/rust.ts index c3a9135..e088e5f 100644 --- a/sandbox/shared/src/rust.ts +++ b/sandbox/shared/src/rust.ts @@ -2,9 +2,10 @@ * The sandbox machines written in Rust (sandbox/shared/rust), wrapped with `fromWasm`. * Build the wasm first: `pnpm build:wasm`. Load it once — `await loadRust()` in a * browser, `loadRustSync(bytes)` (or `./rust-node`) in Node and Bun — then build - * machines with the factories below. + * machines with the factories below. Their types come from the Rust types: the build + * generates them onto each class, and `fromWasm` reads them. */ -import { fromWasm, type Machine } from '@dunky.dev/state-machine' +import { fromWasm, type TypesOf } from '@dunky.dev/state-machine' import init, { DialogMachine, initSync, PaletteMachine } from '../rust/pkg/dunky_sandbox.js' import type { CommandPaletteMachine } from './machine' import type { @@ -28,12 +29,7 @@ export function loadRustSync(bytes: BufferSource): void { /** The command palette. `results` crosses the boundary as indices and maps back onto * the caller's own command objects: Rust owns the ids, rich values stay in JS. */ export function createRustPalette(commands: Command[]): CommandPaletteMachine { - return fromWasm< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed - >(new PaletteMachine(commands), { + return fromWasm(new PaletteMachine(commands), { computed: { results: { from: 'resultIndices', @@ -43,11 +39,23 @@ export function createRustPalette(commands: Command[]): CommandPaletteMachine { }) } -export type DialogState = 'closed' | 'open' | 'closing' -export type DialogContext = { exitMs: number; openCount: number } -export type DialogEvent = { type: 'open' | 'close' | 'toggle' } - /** A dialog whose `closing` phase lasts `exitMs` (an `after` timer on the host clock). */ -export function createRustDialog(exitMs: number): Machine { +export function createRustDialog(exitMs: number) { return fromWasm(new DialogMachine(exitMs)) } + +export type DialogState = TypesOf['state'] +export type DialogContext = TypesOf['context'] +export type DialogEvent = TypesOf['event'] + +// The Rust palette's generated types must be the TS palette's, exactly: `pnpm typecheck` +// fails here as soon as one side changes without the other. +type Same = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Expect = T +type RustPalette = TypesOf +export type RustPaletteMatchesTs = [ + Expect>, + Expect>, + Expect>, + Expect>, +] diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 80669c2..357300a 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -42,5 +42,16 @@ build dunky-core-wasm dunky_core_wasm packages/core/wasm } > packages/core/wasm/bytes.js printf 'export declare const WASM: string\n' > packages/core/wasm/bytes.d.ts +# types <.d.ts> [cargo args...] — a Rust machine's TS types come from its Rust +# types: the crate's `typescript` example prints them for each exported class, and they +# join the class wasm-bindgen declared, where fromWasm reads them. +types() { + local crate=$1 dts=$2 + shift 2 + cargo run -q -p "$crate" --example typescript "$@" >> "$dts" +} + build dunky-sandbox dunky_sandbox sandbox/shared/rust/pkg --features wasm +types dunky-sandbox sandbox/shared/rust/pkg/dunky_sandbox.d.ts --features wasm build dunky-benchmark dunky_benchmark benchmark/rust/pkg +types dunky-benchmark benchmark/rust/pkg/dunky_benchmark.d.ts diff --git a/tsconfig.build.json b/tsconfig.build.json index ba5a59a..ec110c6 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, - { "path": "./benchmark" } + { "path": "./benchmark" }, + { "path": "./sandbox/shared" } ] } From f1621a83610c1d8e0db533fdcc33e496f74f9558 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 21:45:54 +0200 Subject: [PATCH 20/28] perf: no facade reference crosses into wasm 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 --- ARCHITECTURE.md | 6 +- crates/wasm/SPEC.md | 23 ++--- crates/wasm/src/bridge.rs | 62 ++++--------- crates/wasm/src/lib.rs | 67 +++++-------- crates/wasm/src/runtime.rs | 78 +++++++--------- packages/core/src/from-wasm.ts | 14 +-- packages/core/src/machine.ts | 129 +++++++++++++++++--------- packages/core/tests/from-wasm.test.ts | 33 ++++--- 8 files changed, 204 insertions(+), 208 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 03ff289..d6fdc35 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -236,9 +236,9 @@ crates/core-wasm (inlined in the package) your wasm module - Both kinds share the **facade** (`MachineClass` in `packages/core`): it maps names to numbers, holds the context (or its mirror), and fans each change out to the subscribers. Targets, the connector and `compose` see only the `Machine` interface. -- The **protocol** ([`crates/wasm/SPEC.md`](crates/wasm/SPEC.md)): JS passes the facade - with each call into the engine; the engine hands it to every host function of that - call and lets it go afterwards. Rust holds no JS object between calls, so only JS +- The **protocol** ([`crates/wasm/SPEC.md`](crates/wasm/SPEC.md)): a call carries only + numbers (and the JS event). JS keeps the facade whose call is in progress, and the + host functions run for it. Rust holds no JS object of a machine, so only JS references keep a machine alive — a pending timer holds its machine, like a JS timer holds `this`. - **Errors** stay in JS: a host function catches what user code throws, keeps it on the diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md index 932e1ff..313cbd6 100644 --- a/crates/wasm/SPEC.md +++ b/crates/wasm/SPEC.md @@ -48,12 +48,13 @@ machines; the conversion helpers between JS values and Rust values. ## The protocol -### The facade travels with each call +### The facade stays in JS -- JS passes the machine's **facade** with each call into Rust. Rust hands it to every - host function of that call, then lets it go when the outermost call returns. Rust - holds no JS object between calls, so no reference cycle through wasm keeps a machine - alive: only JS references do. +- Rust calls the host only during a call into the machine, so JS keeps the **facade** + whose call is in progress itself (saved and restored around each call, as calls + nest), and every host function runs for it. No reference to the facade crosses: a + call carries only numbers (and the JS event). Rust holds no JS object of a machine, so + no reference cycle through wasm keeps it alive: only JS references do. ### The host @@ -63,10 +64,10 @@ machines; the conversion helpers between JS values and Rust values. - The host functions never throw. Each catches what user code throws, keeps the first error on the facade, and returns a failure status; Rust raises the machine's halt flag and stops the step. -- `notify(facade, state, changedLo, changedHi)`: once per effective change, in order, +- `notify(state, changedLo, changedHi)`: once per effective change, in order, synchronously, with the current state and the context fields changed since the previous notify (the low and high 32 bits of the mask). -- `startTimer(facade, id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels +- `startTimer(id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels it. A due timer calls back into the machine with its id. - For TS machines only, by index in the JS tables, with the event that caused the call (none for a boot or a data-reaction): @@ -120,7 +121,7 @@ machines; the conversion helpers between JS values and Rust values. - The context lives in JS, and JS announces its own writes to the subscribers. It reports a write's change mask to Rust only when the machine has watchers or derived values: one bit per field, 64 bits, and every field past the 63rd shares the last bit. - Without watchers the report only stamps, so it is a plain call: no facade, no status. + Without watchers the report only stamps, so it is a plain call: no status. - JS keeps each derived value with what its last evaluation read: fields, the state, other derived values. A write clears the values that read a written field, a state change clears the values that read the state, and a cleared value clears the values @@ -141,9 +142,9 @@ machines; the conversion helpers between JS values and Rust values. stop, report whether it runs, report the state, read a context field, read a derived value, take a due timer, and report the names (states, event types, fields, derived values, tags per state) the facade maps the numbers onto. -- The facade argument is optional, so the class works on its own: `start()`, - `send(kind, event)`. Once attached, a call without its facade is an engine failure, - since the host needs the facade for every callback. +- The class works on its own too: `start()`, `send(kind, event)`. Once attached, call it + through its `fromWasm` facade only: the host functions run for the facade whose call + is in progress. - An event kind without data is built from the number alone; any other kind is read from the JS event's fields. The event type opts in to that with `#[event(deserialize)]`. - The crate using the macro depends on `wasm-bindgen` at the version this crate pins. diff --git a/crates/wasm/src/bridge.rs b/crates/wasm/src/bridge.rs index dec28a1..09eb36c 100644 --- a/crates/wasm/src/bridge.rs +++ b/crates/wasm/src/bridge.rs @@ -3,15 +3,15 @@ //! the host clock that runs its timers. //! //! The host is one JS object per module. Its functions take numbers and return status -//! codes, so a call crosses the boundary once, with nothing to box or free. JS passes the -//! machine's facade with each call into Rust, and Rust hands it to every host function -//! of that call, then lets it go: Rust holds no JS object between calls, so no -//! JS <-> wasm reference cycle keeps a machine alive. +//! codes, so a call crosses the boundary once, with nothing to box or free. They run for +//! the machine whose call is in progress, which JS keeps track of itself: no reference to +//! the facade crosses, and Rust holds no JS object of a machine, so no JS <-> wasm +//! reference cycle keeps a machine alive. //! //! JS owns the errors: a host function catches what user code throws, keeps it, and //! returns a failure status. Rust only stops the step and reports the status back. -use std::cell::{Cell, OnceCell, Ref, RefCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::rc::Rc; use dunky_core::{Changes, Command, Machine, Types}; @@ -32,11 +32,11 @@ extern "C" { /// Once per effective change. Returns 0, or 1 when an observer threw. #[wasm_bindgen(method)] - fn notify(this: &HostObject, facade: &JsValue, state: u32, lo: u32, hi: u32) -> u32; + fn notify(this: &HostObject, state: u32, lo: u32, hi: u32) -> u32; /// Returns the host's timeout. #[wasm_bindgen(method, js_name = startTimer)] - fn start_timer(this: &HostObject, facade: &JsValue, id: u32, ms: u32) -> JsValue; + fn start_timer(this: &HostObject, id: u32, ms: u32) -> JsValue; #[wasm_bindgen(method, js_name = cancelTimer)] fn cancel_timer(this: &HostObject, timeout: &JsValue); @@ -45,24 +45,13 @@ extern "C" { /// One machine's line to the JS host. pub(crate) struct Link { pub(crate) host: HostObject, - /// The facade of the call in progress; `undefined` between calls. - facade: RefCell, /// The machine's halt flag: a host function failed during the current call. failed: Rc>, } impl Link { pub(crate) fn new(host: HostObject, halt: Rc>) -> Self { - Self { - host, - facade: RefCell::new(JsValue::UNDEFINED), - failed: halt, - } - } - - /// What every host function of the call in progress receives. - pub(crate) fn facade(&self) -> Ref<'_, JsValue> { - self.facade.borrow() + Self { host, failed: halt } } pub(crate) fn fail(&self) { @@ -75,7 +64,7 @@ impl Link { pub(crate) fn notify(&self, state: u32, changes: Changes) { let (lo, hi) = (changes.fields as u32, (changes.fields >> 32) as u32); - if self.host.notify(&self.facade(), state, lo, hi) != OK { + if self.host.notify(state, lo, hi) != OK { self.fail(); } } @@ -114,22 +103,12 @@ impl Bridge { self.link.set(link).is_ok() } - pub(crate) fn attached(&self) -> bool { - self.link.get().is_some() - } - - /// Run `f` on the machine and report a status. `facade` is the JS facade calling; - /// the outermost call keeps it for the host functions, runs the timer commands, then - /// lets it go. `FAILED` means user code failed during this very call: a nested call - /// (JS calling back in) does not report a failure from before it. - pub fn call(&self, facade: JsValue, f: impl FnOnce(&Machine)) -> u32 { + /// Run `f` on the machine and report a status. The outermost call also runs the + /// timer commands. `FAILED` means user code failed during this very call: a nested + /// call (JS calling back in) does not report a failure from before it. + pub fn call(&self, f: impl FnOnce(&Machine)) -> u32 { let depth = self.depth.get(); let link = self.link.get(); - if depth == 0 - && let Some(link) = link - { - *link.facade.borrow_mut() = facade; - } let failed_before = link.is_some_and(|l| l.failed()); self.depth.set(depth + 1); f(&self.machine); @@ -140,7 +119,6 @@ impl Bridge { } if let Some(link) = link { self.run_timers(link); - *link.facade.borrow_mut() = JsValue::UNDEFINED; // The flag is the machine's: the engine raises it for its own failure too. if link.failed.replace(false) { return match self.machine.take_failure() { @@ -166,23 +144,23 @@ impl Bridge { self.failure.borrow_mut().take() } - pub fn start(&self, facade: JsValue) -> u32 { - self.call(facade, |m| m.start()) + pub fn start(&self) -> u32 { + self.call(|m| m.start()) } - pub fn stop(&self, facade: JsValue) -> u32 { - self.call(facade, |m| m.stop()) + pub fn stop(&self) -> u32 { + self.call(|m| m.stop()) } /// The host clock: timer `id` came due. - pub fn fire(&self, id: u32, facade: JsValue) -> u32 { + pub fn fire(&self, id: u32) -> u32 { { let mut timers = self.timers.borrow_mut(); if let Some(pos) = timers.iter().position(|(t, _)| *t == id) { timers.swap_remove(pos); } } - self.call(facade, |m| m.fire_timer(id)) + self.call(|m| m.fire_timer(id)) } /// The core emits timer commands; the host runs them on its clock (`setTimeout`). @@ -193,7 +171,7 @@ impl Bridge { for command in self.machine.take_commands() { match command { Command::StartTimer { id, ms } => { - let timeout = link.host.start_timer(&link.facade(), id, ms); + let timeout = link.host.start_timer(id, ms); self.timers.borrow_mut().push((id, timeout)); } Command::CancelTimer { id } => { diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index c7d055b..4626c75 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -7,7 +7,7 @@ //! ships it (`crates/core-wasm`). //! //! Both speak one protocol with the core package's JS host: events in by kind number, -//! a `notify(handle, state, changedLo, changedHi)` per effective change (synchronously, +//! a `notify(state, changedLo, changedHi)` per effective change (synchronously, //! like the TS subscribers), timers on the host clock (`startTimer` / `cancelTimer`, then //! `fire(id)`), and a status code back from every call (see [`bridge`]). //! @@ -191,36 +191,21 @@ where self.bridge.machine() } - /// Connect to the JS host; `facade` is the JS facade calling. Returns a status; a - /// second attach is an engine failure. - pub fn attach(&self, host: HostObject, facade: JsValue) -> u32 { + /// Connect to the JS host. Returns a status; a second attach is an engine failure. + /// From then on, call the machine through its `fromWasm` facade: the host functions + /// run for the facade whose call is in progress. + pub fn attach(&self, host: HostObject) -> u32 { let link = Rc::new(Link::new(host, self.machine().halt_flag())); if !self.bridge.connect(link.clone()) { return self.bridge.fail("[machine] already attached".into()); } self.machine().set_host(Rc::new(Observer { link })); // Run what the machine did before it had a host (e.g. timers of a start in Rust). - self.bridge.call(facade, |_| {}) - } - - /// The facade a call runs for. `fromWasm` passes it; a bare call has none, which is - /// fine until a host is attached: then the host needs it for every callback. - fn facade(&self, facade: Option) -> Result { - match facade { - Some(facade) => Ok(facade), - None if self.bridge.attached() => Err(self.bridge.fail( - "[machine] this machine is attached: call it through its fromWasm machine".into(), - )), - None => Ok(JsValue::UNDEFINED), - } + self.bridge.call(|_| {}) } /// Send event `kind`; `event` (the JS event object) supplies the payload, if any. - pub fn send(&self, kind: u32, event: JsValue, facade: Option) -> u32 { - let facade = match self.facade(facade) { - Ok(facade) => facade, - Err(status) => return status, - }; + pub fn send(&self, kind: u32, event: JsValue) -> u32 { let names = ::KIND_NAMES; let Some(name) = names.get(kind as usize) else { return self.bridge.fail(format!("[machine] no event type #{kind}")); @@ -243,22 +228,19 @@ where } } }; - self.bridge.call(facade, |m| m.send(event)) + self.bridge.call(|m| m.send(event)) } - pub fn start(&self, facade: Option) -> u32 { - self.facade(facade) - .map_or_else(|status| status, |facade| self.bridge.start(facade)) + pub fn start(&self) -> u32 { + self.bridge.start() } - pub fn stop(&self, facade: Option) -> u32 { - self.facade(facade) - .map_or_else(|status| status, |facade| self.bridge.stop(facade)) + pub fn stop(&self) -> u32 { + self.bridge.stop() } - pub fn fire(&self, id: u32, facade: Option) -> u32 { - self.facade(facade) - .map_or_else(|status| status, |facade| self.bridge.fire(id, facade)) + pub fn fire(&self, id: u32) -> u32 { + self.bridge.fire(id) } /// The engine's failure behind status 3. @@ -363,21 +345,20 @@ macro_rules! export_machine { } /// Connect to the JS host (`fromWasm` does). Calls return a status: 0, 1 when /// JS code failed (the host holds the error), 3 when the engine failed. - pub fn attach(&self, host: $crate::HostObject, facade: ::wasm_bindgen::JsValue) -> u32 { - self.inner.attach(host, facade) + pub fn attach(&self, host: $crate::HostObject) -> u32 { + self.inner.attach(host) } - /// `facade` is passed by `fromWasm`; leave it out when calling directly. - pub fn send(&self, kind: u32, event: ::wasm_bindgen::JsValue, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { - self.inner.send(kind, event, facade) + pub fn send(&self, kind: u32, event: ::wasm_bindgen::JsValue) -> u32 { + self.inner.send(kind, event) } - pub fn start(&self, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { - self.inner.start(facade) + pub fn start(&self) -> u32 { + self.inner.start() } - pub fn stop(&self, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { - self.inner.stop(facade) + pub fn stop(&self) -> u32 { + self.inner.stop() } - pub fn fire(&self, id: u32, facade: ::core::option::Option<::wasm_bindgen::JsValue>) -> u32 { - self.inner.fire(id, facade) + pub fn fire(&self, id: u32) -> u32 { + self.inner.fire(id) } #[wasm_bindgen(js_name = takeFailure)] pub fn take_failure(&self) -> ::core::option::Option<::std::string::String> { diff --git a/crates/wasm/src/runtime.rs b/crates/wasm/src/runtime.rs index 5d5f28c..98319b0 100644 --- a/crates/wasm/src/runtime.rs +++ b/crates/wasm/src/runtime.rs @@ -296,40 +296,39 @@ fn build(spec: &Spec) -> Config { extern "C" { /// 0 false, 1 true, 2 failed. #[wasm_bindgen(method)] - fn guard(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> u32; + fn guard(this: &HostObject, f: u32, event: &JsValue) -> u32; /// 0, or 1 when it failed. `pre` is what to deliver first (see `Pre`), or 0. #[wasm_bindgen(method)] - fn action(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, pre: u32) -> u32; + fn action(this: &HostObject, f: u32, event: &JsValue, pre: u32) -> u32; /// Start an effect; the host keeps its cleanup. 0, or 1 when it failed. `pre` as for /// `action`. #[wasm_bindgen(method)] - fn effect(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue, pre: u32) -> u32; + fn effect(this: &HostObject, f: u32, event: &JsValue, pre: u32) -> u32; /// Deliver `pre` on its own. 0, or 1 when it failed. #[wasm_bindgen(method)] - fn pre(this: &HostObject, facade: &JsValue, pre: u32) -> u32; + fn pre(this: &HostObject, pre: u32) -> u32; /// The delay in ms, or -1 when it failed. #[wasm_bindgen(method)] - fn delay(this: &HostObject, facade: &JsValue, f: u32, event: &JsValue) -> f64; + fn delay(this: &HostObject, f: u32, event: &JsValue) -> f64; /// 0, `CHANGED` when the value changed (it stays in JS), or 1 when it failed. What /// the evaluation read arrives first (`JsMachine::report`) when it differs from the /// previous evaluation. #[wasm_bindgen(method)] - fn computed(this: &HostObject, facade: &JsValue, id: u32) -> u32; + fn computed(this: &HostObject, id: u32) -> u32; /// The index of the first candidate of guard list `list` that passes, -1 for none, /// -2 when a guard failed. #[wasm_bindgen(method)] - fn pick(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue) -> i32; + fn pick(this: &HostObject, list: u32, event: &JsValue) -> i32; /// Run action list `list` in order. 0, or 1 when an action failed. `pre` as for /// `action`. #[wasm_bindgen(method)] - fn actions(this: &HostObject, facade: &JsValue, list: u32, event: &JsValue, pre: u32) -> u32; + fn actions(this: &HostObject, list: u32, event: &JsValue, pre: u32) -> u32; /// Run transition `id` whole, from `from` to `to` (`flags`: 1 stops the effects, 2 /// starts the target's). 0 done, 1 failed before the switch, 2 failed after it, 3 /// failed while starting the effects. `pre` as for `action`. #[wasm_bindgen(method)] fn transition( this: &HostObject, - facade: &JsValue, id: u32, from: u32, to: u32, @@ -363,7 +362,7 @@ impl JsHost { /// Deliver what is held back, now. fn deliver(&self) { let pre = self.take_pre(); - if pre != 0 && self.link.host.pre(&self.link.facade(), pre) != OK { + if pre != 0 && self.link.host.pre(pre) != OK { self.link.fail(); } } @@ -380,7 +379,7 @@ fn with_event(event: Option<&JsEvent>, f: impl FnOnce(&JsValue) -> R) -> R { impl Host for JsHost { fn guard(&self, id: u32, event: Option<&JsEvent>) -> bool { self.deliver(); - match with_event(event, |e| self.link.host.guard(&self.link.facade(), id, e)) { + match with_event(event, |e| self.link.host.guard(id, e)) { 0 => false, 1 => true, _ => { @@ -392,20 +391,14 @@ impl Host for JsHost { fn action(&self, id: u32, event: Option<&JsEvent>) { let pre = self.take_pre(); - if with_event(event, |e| { - self.link.host.action(&self.link.facade(), id, e, pre) - }) != OK - { + if with_event(event, |e| self.link.host.action(id, e, pre)) != OK { self.link.fail(); } } fn effect(&self, id: u32, event: Option<&JsEvent>) { let pre = self.take_pre(); - if with_event(event, |e| { - self.link.host.effect(&self.link.facade(), id, e, pre) - }) != OK - { + if with_event(event, |e| self.link.host.effect(id, e, pre)) != OK { self.link.fail(); } } @@ -420,7 +413,7 @@ impl Host for JsHost { fn delay(&self, id: u32, event: Option<&JsEvent>) -> u32 { self.deliver(); - let ms = with_event(event, |e| self.link.host.delay(&self.link.facade(), id, e)); + let ms = with_event(event, |e| self.link.host.delay(id, e)); if ms.is_nan() || ms < 0.0 { self.link.fail(); return 0; @@ -430,7 +423,7 @@ impl Host for JsHost { fn computed(&self, id: u32) -> Evaluation { self.deliver(); - let status = self.link.host.computed(&self.link.facade(), id); + let status = self.link.host.computed(id); if status & FAILED != 0 { self.link.fail(); } @@ -442,7 +435,7 @@ impl Host for JsHost { fn pick(&self, list: u32, event: Option<&JsEvent>) -> Option { self.deliver(); - match with_event(event, |e| self.link.host.pick(&self.link.facade(), list, e)) { + match with_event(event, |e| self.link.host.pick(list, e)) { -2 => { self.link.fail(); None @@ -453,10 +446,7 @@ impl Host for JsHost { fn actions(&self, list: u32, event: Option<&JsEvent>) { let pre = self.take_pre(); - if with_event(event, |e| { - self.link.host.actions(&self.link.facade(), list, e, pre) - }) != OK - { + if with_event(event, |e| self.link.host.actions(list, e, pre)) != OK { self.link.fail(); } } @@ -490,10 +480,7 @@ impl Host for JsHost { let pre = self.take_pre(); let flags = u32::from(stop) | (u32::from(start) << 1); let outcome = with_event(event, |e| { - let facade = self.link.facade(); - self.link - .host - .transition(&facade, id, from.0, to.0, e, flags, pre) + self.link.host.transition(id, from.0, to.0, e, flags, pre) }); if outcome != OK { self.link.fail(); @@ -539,8 +526,8 @@ pub struct JsMachine { #[wasm_bindgen] impl JsMachine { - /// `host` is the JS host object. Each call takes the facade calling (`facade`), which - /// the host functions of that call receive. + /// `host` is the JS host object; its functions run for the machine whose call is in + /// progress. #[wasm_bindgen(constructor)] pub fn new(config: &JsConfig, host: HostObject) -> JsMachine { let machine = Machine::new(&config.config); @@ -556,17 +543,16 @@ impl JsMachine { JsMachine { bridge, host } } - pub fn send(&self, kind: u32, event: JsValue, facade: JsValue) -> u32 { - self.bridge - .call(facade, |m| m.send(JsEvent { kind, value: event })) + pub fn send(&self, kind: u32, event: JsValue) -> u32 { + self.bridge.call(|m| m.send(JsEvent { kind, value: event })) } - pub fn start(&self, facade: JsValue) -> u32 { - self.bridge.start(facade) + pub fn start(&self) -> u32 { + self.bridge.start() } - pub fn stop(&self, facade: JsValue) -> u32 { - self.bridge.stop(facade) + pub fn stop(&self) -> u32 { + self.bridge.stop() } pub fn running(&self) -> bool { @@ -580,14 +566,14 @@ impl JsMachine { /// JS changed context fields (`lo`/`hi`: the low and high 32 bits of the mask), and /// the machine has watchers: they may run. #[wasm_bindgen(js_name = markChanged)] - pub fn mark_changed(&self, lo: u32, hi: u32, facade: JsValue) -> u32 { - self.bridge.call(facade, |m| { + pub fn mark_changed(&self, lo: u32, hi: u32) -> u32 { + self.bridge.call(|m| { m.mark_changed((u64::from(hi) << 32) | u64::from(lo)); }) } /// JS changed context fields, and the machine has computed values but no watchers: - /// only stamp them. No code runs, so there is no facade and no status. + /// only stamp them. No code runs, so there is no status. pub fn stamp(&self, lo: u32, hi: u32) { self.bridge .machine() @@ -595,8 +581,8 @@ impl JsMachine { } /// Bring computed `id` up to date, evaluating what changed. JS holds the values. - pub fn computed(&self, id: u32, facade: JsValue) -> u32 { - self.bridge.call(facade, |m| { + pub fn computed(&self, id: u32) -> u32 { + self.bridge.call(|m| { m.computed_version(id as usize); }) } @@ -616,8 +602,8 @@ impl JsMachine { } /// The host clock: timer `id` (from `startTimer`) came due. - pub fn fire(&self, id: u32, facade: JsValue) -> u32 { - self.bridge.fire(id, facade) + pub fn fire(&self, id: u32) -> u32 { + self.bridge.fire(id) } /// The engine's failure behind status 3. diff --git a/packages/core/src/from-wasm.ts b/packages/core/src/from-wasm.ts index d1844e0..c85cc48 100644 --- a/packages/core/src/from-wasm.ts +++ b/packages/core/src/from-wasm.ts @@ -1,12 +1,12 @@ -import { HOST, MachineClass, type Engine, type Shape } from './machine' +import { callFor, HOST, MachineClass, type Engine, type Shape } from './machine' import type { Machine } from './types' /** An instance of a class exported with `dunky_wasm::export_machine!`. */ export interface WasmMachine extends Engine { - /** Connect to the host; `facade` is the facade calling. Returns a status. */ - attach: (host: object, facade: object) => number + /** Connect to the host. Returns a status. */ + attach: (host: object) => number field: (index: number) => unknown - computed: (id: number, facade: object) => unknown + computed: (id: number) => unknown meta: () => WasmMachineMeta } @@ -115,17 +115,17 @@ class RustMachine< enumerable: true, get: map ? () => { - const next = handle.computed(source, this) + const next = handle.computed(source) if (next !== raw) { raw = next value = map(next) } return value } - : () => handle.computed(source, this), + : () => handle.computed(source), }) } - const status = handle.attach(HOST, this) + const status = callFor(this, () => handle.attach(HOST)) if (status !== 0) this.raise(status) } diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 0979e5c..72ac6af 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -16,17 +16,17 @@ import type { /** * What the facade drives: a machine on the Rust engine. `JsMachine` (a TS-authored * machine) and every class exported with `dunky_wasm::export_machine!` (a Rust machine) - * implement it. Calls that run machine code return a status (see `OK`). + * implement it. Calls that run machine code return a status (see `OK`); the host + * functions run for the facade whose call is in progress (`calling`). */ export interface Engine { - // `facade` is the facade calling: the host functions of that call receive it. - send: (kind: number, event: unknown, facade: object) => number - start: (facade: object) => number - stop: (facade: object) => number + send: (kind: number, event: unknown) => number + start: () => number + stop: () => number running: () => boolean state: () => number /** The host clock: timer `id` came due. */ - fire: (id: number, facade: object) => number + fire: (id: number) => number /** The engine's failure behind `ENGINE_FAILED`. */ takeFailure: () => string | undefined } @@ -40,6 +40,13 @@ const ENGINE_FAILED = 3 const CHANGED = 2 const NONE = Symbol('none') +// The facade whose engine call is in progress: every host function runs for it. Calls +// nest (user code calls another machine), so each call saves and restores it. Keeping it +// here means no reference to the facade crosses into wasm. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFacade = MachineClass +let calling: AnyFacade = null! + /** The names the facade maps the engine's numbers onto. */ export interface Shape { stateNames: string[] @@ -111,15 +118,21 @@ export class MachineClass< send = (event: Event): void => { const kind = this.shape.kinds.get(event.type) if (kind === undefined) return // handled nowhere: a no-op + const outer = calling + calling = this this.depth++ - const status = this.handle.send(kind, event, this) + const status = this.handle.send(kind, event) this.depth-- + calling = outer if (status !== OK) this.raise(status) } fire(id: number): void { + const outer = calling + calling = this this.depth++ - const status = this.handle.fire(id, this) + const status = this.handle.fire(id) this.depth-- + calling = outer if (status !== OK) this.raise(status) } @@ -139,18 +152,24 @@ export class MachineClass< start = (): void => { if (this.isRunning) return this.isRunning = true + const outer = calling + calling = this this.depth++ - const status = this.handle.start(this) + const status = this.handle.start() this.depth-- + calling = outer if (status !== OK) this.raise(status) if (this.startListeners) for (const fn of this.startListeners) fn() } stop = (): void => { if (!this.isRunning) return this.isRunning = false + const outer = calling + calling = this this.depth++ - const status = this.handle.stop(this) + const status = this.handle.stop() this.depth-- + calling = outer if (status !== OK) this.raise(status) if (this.stopListeners) for (const fn of this.stopListeners) fn() } @@ -325,9 +344,12 @@ class TsMachine< if ((lo | hi) === 0) return const read = this.cache !== null && this.clear(lo, hi, false) if (this.c.watches) { + const outer = calling + calling = this this.depth++ - const status = this.handle.markChanged(lo >>> 0, hi >>> 0, this) + const status = this.handle.markChanged(lo >>> 0, hi >>> 0) this.depth-- + calling = outer if (status !== OK) this.raise(status) } else if (read) { // The engine's stamps only matter for fields a computed value read. @@ -362,9 +384,12 @@ class TsMachine< // Every input of a TS machine changes through JS, so a valid entry is the value. const entry = this.cache![id]! if (entry.valid) return entry.value + const outer = calling + calling = this this.depth++ - const status = this.handle.computed(id, this) + const status = this.handle.computed(id) this.depth-- + calling = outer if (status !== OK) this.raise(status) entry.valid = true return entry.value @@ -535,35 +560,32 @@ class TsMachine< } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Facade = MachineClass // eslint-disable-next-line @typescript-eslint/no-explicit-any type TsFacade = TsMachine type Timeout = ReturnType -/** The JS functions the engine calls back into (see crates/wasm/src/bridge.rs). They - * never throw: each catches what user code throws, keeps it on the machine, and returns - * a failure status. */ +/** The JS functions the engine calls back into (see crates/wasm/src/bridge.rs), for the + * facade whose call is in progress (`calling`). They never throw: each catches what user + * code throws, keeps it on the machine, and returns a failure status. */ interface Host { /** 0 false, 1 true, 2 failed. */ - guard: (m: TsFacade, fn: number, event: unknown) => number + guard: (fn: number, event: unknown) => number /** `pre`: what rides on this call, delivered first (see `prelude`), or 0. */ - action: (m: TsFacade, fn: number, event: unknown, pre: number) => number + action: (fn: number, event: unknown, pre: number) => number /** Start an effect; its cleanup waits in `effectCleanups`. */ - effect: (m: TsFacade, fn: number, event: unknown, pre: number) => number + effect: (fn: number, event: unknown, pre: number) => number /** Deliver `pre` on its own. */ - pre: (m: TsFacade, pre: number) => number + pre: (pre: number) => number /** The delay in ms, -1 failed. */ - delay: (m: TsFacade, fn: number, event: unknown) => number + delay: (fn: number, event: unknown) => number /** 0, `CHANGED` when the value changed (it stays in JS), 1 failed. */ - computed: (m: TsFacade, id: number) => number + computed: (id: number) => number /** The first candidate of guard list `list` that passes, -1 for none, -2 failed. */ - pick: (m: TsFacade, list: number, event: unknown) => number - actions: (m: TsFacade, list: number, event: unknown, pre: number) => number + pick: (list: number, event: unknown) => number + actions: (list: number, event: unknown, pre: number) => number /** Run transition `id` whole (`flags`: 1 stops the effects, 2 starts the target's). * 0 done, 1 failed before the switch, 2 after it, 3 while starting the effects. */ transition: ( - m: TsFacade, id: number, from: number, to: number, @@ -571,17 +593,26 @@ interface Host { flags: number, pre: number, ) => number - notify: (m: Facade, state: number, lo: number, hi: number) => number - startTimer: (m: Facade, id: number, ms: number) => Timeout + notify: (state: number, lo: number, hi: number) => number + startTimer: (id: number, ms: number) => Timeout cancelTimer: (timeout: Timeout) => void } -/** - * The host functions Rust calls, with the facade of the call in progress and, for user - * code, the callback's index in the compiled tables. - */ +/** Run `call` as an engine call of `facade`, so the host functions run for it. */ +export function callFor(facade: AnyFacade, call: () => number): number { + const outer = calling + calling = facade + facade.depth++ + const status = call() + facade.depth-- + calling = outer + return status +} + +/** The host functions Rust calls; user code by its index in the compiled tables. */ export const HOST: Host = { - guard: (m, fn, event) => { + guard: (fn, event) => { + const m = calling as TsFacade try { return m.c.guards[fn]!(m.guardParams(event)) ? 1 : 0 } catch (error) { @@ -589,7 +620,8 @@ export const HOST: Host = { return 2 } }, - action: (m, fn, event, pre) => { + action: (fn, event, pre) => { + const m = calling as TsFacade try { if (pre) m.prelude(pre) m.c.actions[fn]!(m.actionParams(event)) @@ -598,7 +630,8 @@ export const HOST: Host = { return m.fail(error) } }, - effect: (m, fn, event, pre) => { + effect: (fn, event, pre) => { + const m = calling as TsFacade try { if (pre) m.prelude(pre) const cleanup = m.c.effects[fn]!(m.actionParams(event)) @@ -608,7 +641,8 @@ export const HOST: Host = { return m.fail(error) } }, - pre: (m, pre) => { + pre: pre => { + const m = calling as TsFacade try { m.prelude(pre) return OK @@ -616,7 +650,8 @@ export const HOST: Host = { return m.fail(error) } }, - delay: (m, fn, event) => { + delay: (fn, event) => { + const m = calling as TsFacade try { const ms = m.c.delays[fn]!(m.guardParams(event)) return ms > 0 ? ms : 0 // like setTimeout: negative or NaN means now @@ -625,14 +660,16 @@ export const HOST: Host = { return -1 } }, - computed: (m, id) => { + computed: id => { + const m = calling as TsFacade try { return m.evaluate(id) } catch (error) { return m.fail(error) } }, - pick: (m, list, event) => { + pick: (list, event) => { + const m = calling as TsFacade try { const candidates = m.c.guardLists[list]! const params = m.guardParams(event) @@ -646,7 +683,8 @@ export const HOST: Host = { return -2 } }, - actions: (m, list, event, pre) => { + actions: (list, event, pre) => { + const m = calling as TsFacade try { if (pre) m.prelude(pre) const actions = m.c.actionLists[list]! @@ -657,7 +695,8 @@ export const HOST: Host = { return m.fail(error) } }, - transition: (m, id, from, to, event, flags, pre) => { + transition: (id, from, to, event, flags, pre) => { + const m = calling as TsFacade let phase = 1 try { if (pre) m.prelude(pre) @@ -683,7 +722,8 @@ export const HOST: Host = { return phase } }, - notify: (m, state, lo, hi) => { + notify: (state, lo, hi) => { + const m = calling try { if (lo | hi) { m.current = state @@ -696,7 +736,10 @@ export const HOST: Host = { } }, // The pending timer holds the machine, like a JS timer holds `this`. - startTimer: (m, id, ms) => setTimeout(() => m.fire(id), ms), + startTimer: (id, ms) => { + const m = calling + return setTimeout(() => m.fire(id), ms) + }, cancelTimer: timeout => clearTimeout(timeout), } diff --git a/packages/core/tests/from-wasm.test.ts b/packages/core/tests/from-wasm.test.ts index 7f170e2..e8d8953 100644 --- a/packages/core/tests/from-wasm.test.ts +++ b/packages/core/tests/from-wasm.test.ts @@ -12,7 +12,8 @@ const FIELDS = Array.from({ length: 40 }, (_, i) => `f${i}`) function fake() { let host: typeof HOST - let me: object + // Rust calls the host only during a call into it: a change waits for the next event. + let pending: (() => void) | null = null const rust = { state: 0, values: FIELDS.map(() => 0), @@ -20,12 +21,15 @@ function fake() { } const reads = { field: 0 } const handle = { - attach: (h: object, ref: object) => { + attach: (h: object) => { host = h as typeof HOST - me = ref return 0 }, - send: vi.fn((_kind: number, _event: unknown, _facade: object) => 0), + send: vi.fn((_kind: number, _event: unknown) => { + pending?.() + pending = null + return 0 + }), start: () => 0, stop: () => 0, running: () => false, @@ -45,17 +49,19 @@ function fake() { tags: [[], ['working']], }), } satisfies WasmMachine - /** Rust changed: the state, and the fields at `changed`. */ + /** On the next event, Rust changes the state and the fields at `changed`. */ const change = (state: number, changed: Record) => { - rust.state = state - let lo = 0 - let hi = 0 - for (const [i, value] of Object.entries(changed)) { - rust.values[Number(i)] = value - if (Number(i) < 32) lo |= 1 << Number(i) - else hi |= 1 << (Number(i) - 32) + pending = () => { + rust.state = state + let lo = 0 + let hi = 0 + for (const [i, value] of Object.entries(changed)) { + rust.values[Number(i)] = value + if (Number(i) < 32) lo |= 1 << Number(i) + else hi |= 1 << (Number(i) - 32) + } + host.notify(state, lo >>> 0, hi >>> 0) } - host.notify(me as never, state, lo >>> 0, hi >>> 0) } return { handle, rust, reads, change } } @@ -83,6 +89,7 @@ describe('fromWasm', () => { m.subscribe(listener) reads.field = 0 change(1, { 1: 7, 35: 9 }) + m.send({ type: 'go' }) expect(listener).toHaveReturnedWith(['busy', 7, 9]) expect(m.hasTag('working')).toBe(true) expect(reads.field).toBe(2) From 4e492a4fb098cc653f08d038067bd5c641d55f58 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 7 Oct 2026 21:53:12 +0200 Subject: [PATCH 21/28] perf(benchmark): numbers with no facade crossing 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 --- README.md | 2 +- benchmark/README.md | 101 +++++++++++++------------ packages/core/README.md | 2 +- website/src/content/docs/benchmark.mdx | 30 ++++---- 4 files changed, 68 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index c606e16..585fe20 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ engine is built for it: | At scale (thousands of machines) | Dunky | XState | Zag | | -------------------------------- | --------: | -----: | ------: | -| Event throughput (ops/s) | **9.4 M** | 1.6 M | n/a ᵃ | +| Event throughput (ops/s) | **9.9 M** | 1.6 M | n/a ᵃ | | Memory / machine, 2-field (KB) | **3.5** | 3.6 | 8.9 | | Memory / machine, 64-field (KB) | **4.0** | 4.1 | **134** | diff --git a/benchmark/README.md b/benchmark/README.md index c6cbe4b..6506c75 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -97,11 +97,11 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 663.3 K | 455.2 K | n/a ᵃ | -| 1000 | 58.4 K | 50.0 K | n/a ᵃ | -| 5000 | **21.2 K** | 1.4 K | n/a ᵃ | +| 100 | 659.3 K | 445.2 K | n/a ᵃ | +| 1000 | 53.5 K | 52.4 K | n/a ᵃ | +| 5000 | **19.1 K** | 1.3 K | n/a ᵃ | -→ Roughly par at small N, but Dunky **~15× faster at 5000 observers** — XState's +→ Roughly par at small N, but Dunky **~14× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. **B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The @@ -111,8 +111,8 @@ model gets for free and a coarse bus has to work to ignore. | Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **4.9 M** | 1.0 M | n/a ᵃ | -| 5000 | **4.7 M** | 942.2 K | n/a ᵃ | +| 1000 | **4.8 M** | 885.7 K | n/a ᵃ | +| 5000 | **3.9 M** | 842.4 K | n/a ᵃ | → Dunky is **~5× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. @@ -122,7 +122,7 @@ selection scaling — the raw `send` price. | Single machine, one event | ops/sec | | ------------------------- | --------: | -| Dunky | **9.4 M** | +| Dunky | **9.9 M** | | XState (raw) | 1.6 M | | XState (diffed) | 1.6 M | | Zag | n/a ᵃ | @@ -144,12 +144,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 8.9 M | 9.1 M | n/a ᶠ | n/a ᶠ | -| 10 | 8.0 M | 8.6 M | n/a ᶠ | n/a ᶠ | -| 50 | 7.3 M | 7.1 M | n/a ᶠ | n/a ᶠ | +| 2 | 9.3 M | 9.7 M | n/a ᶠ | n/a ᶠ | +| 10 | 8.7 M | 8.9 M | n/a ᶠ | n/a ᶠ | +| 50 | 7.3 M | 7.8 M | n/a ᶠ | n/a ᶠ | -→ Cross-region coordination stays in the **~7.1–9.1 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~18–22% going from 2 to 50. +→ Cross-region coordination stays in the **~7.3–9.7 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~20–22% going from 2 to 50. > A third "chain" sub-test (a sync rule that `send()`s downstream every change) was > removed: under a tight loop it shows superlinear slowdown. That's a real @@ -165,13 +165,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z | Scenario | Dunky (ops/sec) | XState | Zag | | ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **57.8 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 8.3 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 4.5 M | n/a ᶠ | n/a ᵃ | +| Cached read (no change) | **56.2 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 8.6 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 4.7 M | n/a ᶠ | n/a ᵃ | | 4-deep chain (change root, read tip) | 2.3 M | n/a ᶠ | n/a ᵃ | -→ A cached read is **~57.8 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~8.3 M/sec — read-key tracking means +→ A cached read is **~56.2 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~8.6 M/sec — read-key tracking means you only pay the recompute when an input you actually read changes. ## 4. Engine hot paths (`tests/engine.ts`) @@ -183,18 +183,18 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.3 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 4.0 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 8.0 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 7.1 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 9.8 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 7.2 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~4.0–9.8 M ops/sec**: a 32-candidate guard walk +| Guard fallthrough — 2 candidates | 4.8 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.6 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 4.2 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 8.4 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 7.3 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 10.4 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 7.6 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~4.2–10.4 M ops/sec**: a 32-candidate guard walk (evaluated in one call from the engine into JS), full state transitions with entry/exit actions, and effect boot/cleanup every transition all stay within -~2.3× of a bare `send`. +~2.4× of a bare `send`. ## 5. Construction cost (`tests/construct.ts`) @@ -204,11 +204,11 @@ warmed first. | Build + start | Dunky (µs/machine) | XState | Zag | | ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.35 | 1.33 | 5.52 | +| 10 000 | 1.36 | 1.07 | 5.02 | → Construction is the one axis where Dunky **doesn't** win — XState spins up -as fast or slightly faster (~1.0×). Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~4.1× faster than Zag's per-field reactive cells. +~1.3× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.7× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) @@ -220,7 +220,7 @@ the footprint a churny app actually pays). | Context | Dunky (KB/machine) | XState | Zag | | -------- | -----------------: | -----: | ------: | -| 2-field | 3.47 | 3.61 | 8.92 | +| 2-field | 3.46 | 3.62 | 8.92 | | 64-field | 3.99 | 4.10 | **134** | → Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with @@ -234,7 +234,7 @@ while a lazy-copy scheme steps up once writes start: | 64-field, 5000 machines | Dunky | XState | Zag | | ----------------------- | ----: | -----: | --: | -| Idle (never written) | 3.97 | 3.55 | 130 | +| Idle (never written) | 3.96 | 3.55 | 130 | | Written (1 event each) | 3.99 | 4.10 | 134 | → Dunky idle ≈ written; XState's first `assign` allocates a per-actor context, @@ -254,14 +254,14 @@ List of 1000 rows: | Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | | -------------------- | ----------------: | ---------: | ------------------: | | Dunky/instance | **2** | 3.6 | **2.4** | -| Dunky/selector | 2 | 5.0 | 3.8 | -| xstate/selector | 2 | 3.6 | 4.5 | -| zag/instance | 2 | 4.1 | n/a ᵃ | -| naive (anti-pattern) | **980** | 5.0 | 35.3 | +| Dunky/selector | 2 | 4.9 | 3.8 | +| xstate/selector | 2 | 3.6 | 4.4 | +| zag/instance | 2 | 4.2 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.9 | 34.9 | → Every properly-set-up engine wakes only the **2** rows that changed (vs. the naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~15× the wall time). Among the surgical strategies Dunky re-renders **~1.9× +~15× the wall time). Among the surgical strategies Dunky re-renders **~1.8× faster than XState**. Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the @@ -278,26 +278,27 @@ wasm handle with no facade: the floor. A call into wasm costs the same as a JS c | Scenario | TS machine (ops/sec) | Rust machine | Rust, raw | | ----------------------------------------- | -------------------: | -----------: | --------: | -| One event, observed field | **9.8 M** | 6.6 M | 13.4 M | -| One event, unobserved field | **9.9 M** | 6.3 M | 13.3 M | -| State churn — exit+entry every event | **9.5 M** | 6.4 M | 9.1 M | -| Guard fallthrough — 8 candidates | **8.2 M** | 8.0 M | — | -| Palette — type + read, 11 commands | **830.2 K** | 429.2 K | — | -| Palette — type + read, 1,000 commands | **13.3 K** | 8.4 K | — | -| Palette — type + read, 10,000 commands | **1.3 K** | 836 | — | -| Construct + start one machine (µs / mach) | **4.56** | 5.37 | 2.05 | - -→ In JavaScript, a machine written in TS handles **~1.0–1.6× the events** of its Rust +| One event, observed field | **10.7 M** | 8.5 M | 15.5 M | +| One event, unobserved field | **10.8 M** | 8.1 M | 14.9 M | +| State churn — exit+entry every event | **10.4 M** | 8.0 M | 9.8 M | +| Guard fallthrough — 8 candidates | 8.9 M | **9.7 M** | — | +| Palette — type + read, 11 commands | **881.6 K** | 433.9 K | — | +| Palette — type + read, 1,000 commands | **13.1 K** | 8.2 K | — | +| Palette — type + read, 10,000 commands | **1.3 K** | 819 | — | +| Construct + start one machine (µs / mach) | 4.54 | **4.30** | 2.30 | + +→ In JavaScript, a machine written in TS handles **~1.3× the events** of its Rust twin. Its context already lives in JS, while a Rust machine pushes each change back across the boundary: a notify, then a read per changed field to refresh the JS context mirror. The raw handle (no subscribers, no mirror) shows the engine itself: -~13 M events/sec. +~15 M events/sec. A guard walk is the exception: there the Rust machine is ~1.1× +faster, since its guards never leave wasm. -→ Heavy derived data favors TS too: the palette filters **~1.6–1.9× faster** as a TS +→ Heavy derived data favors TS too: the palette filters **~1.6–2.0× faster** as a TS machine, because its filter runs as JIT-compiled JS and its results are already JS values, while the Rust machine serializes its results on each change. Crossing as indices mapped back onto your own objects (the rows above) beats crossing whole -objects by ~22% (6.9 K at 1,000 commands). +objects by ~22% (6.7 K at 1,000 commands). → Rust machines are for reuse: the same machine runs in a Rust program and over JSI, where there is no JS mirror to keep. In React ([section 7](#7-react-rendering-testsrendering)) diff --git a/packages/core/README.md b/packages/core/README.md index c8f14b8..05416d1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -114,7 +114,7 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers. -In practice that's **~6× the event throughput** of the alternatives (~15× once +In practice that's **~6× the event throughput** of the alternatives (~14× once thousands of observers watch one machine), flat memory as context grows wide, and surgical re-renders that wake only the rows that actually changed. diff --git a/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index bfb2637..b2be4b8 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -42,7 +42,7 @@ These were run on a **MacBook Pro (M5 Pro, 64 GB)** with Node 24. The engine is | | Dunky | XState | Zag | | ------------------------- | --------------: | -----: | -----: | -| Event throughput | **9.4 M ops/s** | 1.6 M | n/a ᵃ | +| Event throughput | **9.9 M ops/s** | 1.6 M | n/a ᵃ | | Memory, 2-field context | **3.5 KB** | 3.6 KB | 8.9 KB | | Memory, 64-field context | **4.0 KB** | 4.1 KB | 134 KB | | Re-render wall, 1000 rows | **2.4 ms** | 4.5 ms | n/a ᵃ | @@ -55,7 +55,7 @@ A single machine, one event, tight loop: | | Dunky | XState | Zag | | ------- | --------: | -----: | ----: | -| ops/sec | **9.4 M** | 1.6 M | n/a ᵃ | +| ops/sec | **9.9 M** | 1.6 M | n/a ᵃ | XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing. @@ -65,8 +65,8 @@ Change a field no observer has selected. The dedup layer re-evaluates and value- | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 1 000 | **4.9 M** | 1.0 M | -| 5 000 | **4.7 M** | 942.2 K | +| 1 000 | **4.8 M** | 885.7 K | +| 5 000 | **3.9 M** | 842.4 K | XState's `actor.subscribe` is coarse: it fires on every snapshot change. To match Dunky's behavior you'd add a differ in the listener, which is what the `xstate` column already does, for a fair comparison. @@ -77,9 +77,9 @@ affected observer" cycles complete per second (higher is better): | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 100 | 663.3 K | 455.2 K | -| 1 000 | 58.4 K | 50.0 K | -| 5 000 | **21.2 K** | 1.4 K | +| 100 | 659.3 K | 445.2 K | +| 1 000 | 53.5 K | 52.4 K | +| 5 000 | **19.1 K** | 1.3 K | Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one. @@ -100,9 +100,9 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 1.35 | **1.33** | 5.52 | +| µs / machine | 1.36 | **1.07** | 5.02 | -XState cold-starts as fast or slightly faster (~1.0×); Zag is ~4.1× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). +XState cold-starts ~1.3× faster; Zag is ~3.7× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). ## Machines written in TS vs Rust @@ -110,13 +110,13 @@ Every machine runs on the Rust engine, but you can write it in TypeScript or in | Scenario (ops/s) | TS machine | Rust machine | | ---------------------------------- | ---------: | -----------: | -| One event | **9.8 M** | 6.6 M | -| State churn (exit + entry actions) | **9.5 M** | 6.4 M | -| Guard fallthrough, 8 candidates | **8.2 M** | 8.0 M | -| Command palette, 1,000 commands | **13.3 K** | 8.4 K | -| Command palette, 10,000 commands | **1.3 K** | 836 | +| One event | **10.7 M** | 8.5 M | +| State churn (exit + entry actions) | **10.4 M** | 8.0 M | +| Guard fallthrough, 8 candidates | 8.9 M | **9.7 M** | +| Command palette, 1,000 commands | **13.1 K** | 8.2 K | +| Command palette, 10,000 commands | **1.3 K** | 819 | -In JavaScript, a TS machine is faster: its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. In React both render alike: a 1000-row list re-renders in about 2.4 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. +In JavaScript, a TS machine is usually faster (~1.3× on events): its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. A guard walk is the exception: in a Rust machine it never leaves wasm. In React both render alike: a 1000-row list re-renders in about 2.4 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. ## Where this matters From 8b1e81bd195d8cbdcf94c4373526972a64b3363e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 12:02:00 +0200 Subject: [PATCH 22/28] refactor: two engines, one spec 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 --- .cargo/config.toml | 3 + .changeset/rust-engine.md | 44 - .changeset/use-machine-accepts-machine.md | 25 - .github/actions/wasm-setup/action.yml | 36 - .github/workflows/ci-checks.yml | 12 +- .github/workflows/deploy-benchmark.yml | 2 - .github/workflows/release.yml | 2 - .gitignore | 4 - .oxlintrc.json | 7 +- AGENTS.md | 50 +- ARCHITECTURE.md | 280 ++---- CONTRIBUTING.md | 47 +- Cargo.lock | 159 --- Cargo.toml | 28 +- README.md | 34 +- benchmark/README.md | 150 +-- benchmark/index.ts | 4 - benchmark/package.json | 3 +- benchmark/rust.ts | 87 -- benchmark/rust/Cargo.toml | 20 - benchmark/rust/examples/typescript.rs | 6 - benchmark/rust/src/cell.rs | 52 - benchmark/rust/src/guards.rs | 47 - benchmark/rust/src/highlight.rs | 44 - benchmark/rust/src/lib.rs | 119 --- benchmark/rust/src/list.rs | 44 - benchmark/rust/src/pingpong.rs | 44 - benchmark/tests/memory.ts | 13 +- benchmark/tests/rendering/bench.tsx | 77 +- benchmark/tests/wasm.ts | 291 ------ benchmark/wasm-built.ts | 12 - crates/core-wasm/Cargo.toml | 14 - crates/core-wasm/src/lib.rs | 48 - crates/core/Cargo.toml | 3 - crates/core/README.md | 14 +- crates/core/SPEC.md | 126 +-- crates/core/src/computed.rs | 85 +- crates/core/src/config.rs | 106 -- crates/core/src/lib.rs | 22 +- crates/core/src/machine.rs | 433 +------- crates/core/src/protocol.rs | 5 +- crates/core/src/timers.rs | 4 +- crates/core/src/ts.rs | 168 ---- crates/core/tests/derive.rs | 51 +- crates/macros/Cargo.toml | 2 +- crates/macros/src/lib.rs | 67 +- crates/macros/src/ts.rs | 298 ------ crates/sandbox/Cargo.toml | 14 + .../rust => crates/sandbox}/src/dialog.rs | 0 crates/sandbox/src/lib.rs | 11 + .../rust => crates/sandbox}/src/palette.rs | 8 +- crates/uniffi/Cargo.toml | 2 +- crates/wasm/Cargo.toml | 19 - crates/wasm/README.md | 53 - crates/wasm/SPEC.md | 162 --- crates/wasm/src/bridge.rs | 190 ---- crates/wasm/src/lib.rs | 412 -------- crates/wasm/src/runtime.rs | 614 ------------ package.json | 3 - packages/core/README.md | 58 +- packages/core/SPEC.md | 93 +- packages/core/src/actions.ts | 56 +- packages/core/src/compile.ts | 283 ------ packages/core/src/computed.ts | 120 +++ packages/core/src/constants.ts | 9 + packages/core/src/from-wasm.ts | 179 ---- packages/core/src/index.ts | 11 +- packages/core/src/machine.ts | 937 +++++------------- packages/core/src/transitions.ts | 57 ++ packages/core/src/wasm.ts | 21 - packages/core/tests/computed.test.ts | 69 -- packages/core/tests/from-wasm.test.ts | 114 --- packages/react/SPEC.md | 208 ---- packages/react/src/use-machine.ts | 23 +- packages/react/tests/use-machine.test.tsx | 19 - packages/solid/SPEC.md | 212 ---- packages/solid/src/use-machine.ts | 23 +- packages/solid/tests/use-machine.test.tsx | 19 - pnpm-lock.yaml | 3 - sandbox/README.md | 19 +- sandbox/native/app.json | 1 - sandbox/opentui/package.json | 3 +- sandbox/opentui/src/app.tsx | 10 +- sandbox/opentui/src/command-palette.tsx | 10 +- sandbox/opentui/src/index.tsx | 17 +- sandbox/react/src/app.tsx | 14 +- sandbox/react/src/command-palette.tsx | 15 +- sandbox/react/src/main.tsx | 29 +- sandbox/shared/package.json | 4 +- sandbox/shared/rust/Cargo.toml | 28 - sandbox/shared/rust/examples/typescript.rs | 6 - sandbox/shared/rust/src/lib.rs | 61 -- sandbox/shared/src/index.ts | 1 - sandbox/shared/src/rust-node.ts | 9 - sandbox/shared/src/rust.ts | 61 -- sandbox/shared/src/types.ts | 13 - sandbox/shared/tests/rust.test.ts | 94 -- sandbox/solid/src/app.tsx | 14 +- sandbox/solid/src/command-palette.tsx | 13 +- sandbox/solid/src/main.tsx | 21 +- scripts/build-wasm.sh | 57 -- tsconfig.build.json | 3 +- vitest.config.ts | 2 +- website/src/content/docs/benchmark.mdx | 56 +- 104 files changed, 949 insertions(+), 6806 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 .changeset/rust-engine.md delete mode 100644 .changeset/use-machine-accepts-machine.md delete mode 100644 .github/actions/wasm-setup/action.yml delete mode 100644 benchmark/rust.ts delete mode 100644 benchmark/rust/Cargo.toml delete mode 100644 benchmark/rust/examples/typescript.rs delete mode 100644 benchmark/rust/src/cell.rs delete mode 100644 benchmark/rust/src/guards.rs delete mode 100644 benchmark/rust/src/highlight.rs delete mode 100644 benchmark/rust/src/lib.rs delete mode 100644 benchmark/rust/src/list.rs delete mode 100644 benchmark/rust/src/pingpong.rs delete mode 100644 benchmark/tests/wasm.ts delete mode 100644 benchmark/wasm-built.ts delete mode 100644 crates/core-wasm/Cargo.toml delete mode 100644 crates/core-wasm/src/lib.rs delete mode 100644 crates/core/src/ts.rs delete mode 100644 crates/macros/src/ts.rs create mode 100644 crates/sandbox/Cargo.toml rename {sandbox/shared/rust => crates/sandbox}/src/dialog.rs (100%) create mode 100644 crates/sandbox/src/lib.rs rename {sandbox/shared/rust => crates/sandbox}/src/palette.rs (97%) delete mode 100644 crates/wasm/Cargo.toml delete mode 100644 crates/wasm/README.md delete mode 100644 crates/wasm/SPEC.md delete mode 100644 crates/wasm/src/bridge.rs delete mode 100644 crates/wasm/src/lib.rs delete mode 100644 crates/wasm/src/runtime.rs delete mode 100644 packages/core/src/compile.ts create mode 100644 packages/core/src/computed.ts delete mode 100644 packages/core/src/from-wasm.ts create mode 100644 packages/core/src/transitions.ts delete mode 100644 packages/core/src/wasm.ts delete mode 100644 packages/core/tests/from-wasm.test.ts delete mode 100644 packages/react/SPEC.md delete mode 100644 packages/solid/SPEC.md delete mode 100644 sandbox/shared/rust/Cargo.toml delete mode 100644 sandbox/shared/rust/examples/typescript.rs delete mode 100644 sandbox/shared/rust/src/lib.rs delete mode 100644 sandbox/shared/src/rust-node.ts delete mode 100644 sandbox/shared/src/rust.ts delete mode 100644 sandbox/shared/tests/rust.test.ts delete mode 100755 scripts/build-wasm.sh diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b49e099 --- /dev/null +++ b/.cargo/config.toml @@ -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" diff --git a/.changeset/rust-engine.md b/.changeset/rust-engine.md deleted file mode 100644 index eb0703d..0000000 --- a/.changeset/rust-engine.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@dunky.dev/state-machine': minor ---- - -The engine is now Rust, compiled to WebAssembly. The API does not change: -`machine(config)`, `setup`, the connector and every target work as before, and -the 0.x test suite passes unchanged on the new engine. Rust runs the graph — the -queue, transition resolution, entry/exit order, effects, `after` timers, watchers -and computed bookkeeping — and calls back into your guards, actions, effects, -delays and computed definitions. The wasm is inlined, so the package starts -synchronously on the first `machine()` call: there is no `await init()`. - -Machines can now also be written in Rust, on the `dunky-core` crate. Export the -machine type with `dunky_wasm::export_machine!`, build it with `wasm-bindgen`, -and wrap an instance with `fromWasm`: the connector, `useMachine` and every -target consume it like a TS machine, with the same notifications (one per -change, in order). - -```ts -import { fromWasm } from '@dunky.dev/state-machine' -import init, { PaletteMachine } from './pkg/machines.js' // your wasm-bindgen output - -await init() -// Its types come from the Rust types: no generics to write, nothing to keep in sync. -const palette = fromWasm(new PaletteMachine(commands)) -``` - -A Rust machine's TypeScript types are generated from its Rust types (the derives -plus `#[derive(TsType)]` on your data types): `PaletteMachine::typescript(&config)` -returns them for the wasm-bindgen `.d.ts`, and `fromWasm` infers them from the class. - -The `computed` option of `fromWasm` maps a raw value once per change — for -example, indices that crossed the boundary back onto your own objects. - -Breaking: - -- The package needs WebAssembly: browsers, Node, Bun and Deno have it. React - Native (Hermes) does not, so this version does not run there yet. -- The runaway guard (one run-to-completion step over 10,000 events or watcher - runs) now throws in production too, instead of hanging. -- Computed values refresh on writes made through `setContext`, like watchers and - subscribers always did; mutating the context object directly does not refresh - them. -- The bundle carries the engine: about 75 kB gzip. diff --git a/.changeset/use-machine-accepts-machine.md b/.changeset/use-machine-accepts-machine.md deleted file mode 100644 index 076cb00..0000000 --- a/.changeset/use-machine-accepts-machine.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@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 -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. diff --git a/.github/actions/wasm-setup/action.yml b/.github/actions/wasm-setup/action.yml deleted file mode 100644 index 31ffaa1..0000000 --- a/.github/actions/wasm-setup/action.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Setup wasm -description: Build the wasm modules (pnpm build:wasm) — the engine inside @dunky.dev/state-machine, and the Rust machines of the sandbox and the benchmark — 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 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 9ff7ddf..65ac498 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -25,8 +25,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup - # @dunky.dev/state-machine ships the engine's wasm (packages/core/wasm). - - uses: ./.github/actions/wasm-setup - run: pnpm build test: @@ -35,8 +33,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup - # The engine (packages/core/wasm) and the Rust machines of the sandbox tests. - - uses: ./.github/actions/wasm-setup - run: pnpm test:ci typecheck: @@ -45,8 +41,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup - # The core package and the Rust-machine loaders import the generated bindings. - - uses: ./.github/actions/wasm-setup - run: pnpm typecheck lint: @@ -66,6 +60,7 @@ jobs: - 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 @@ -80,10 +75,9 @@ jobs: . -> target crates/uniffi -> target - run: cargo fmt --all --check - - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo lint - run: cargo test --workspace - # Again in release: overflow checks and debug assertions are off there, as - # in the shipped wasm. + # 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. diff --git a/.github/workflows/deploy-benchmark.yml b/.github/workflows/deploy-benchmark.yml index ea19de7..88ad315 100644 --- a/.github/workflows/deploy-benchmark.yml +++ b/.github/workflows/deploy-benchmark.yml @@ -21,8 +21,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup - # The demo runs @dunky.dev/state-machine, which loads the engine's wasm. - - uses: ./.github/actions/wasm-setup - name: Build demo # The canonical demo ships with the docs site at diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b241a1..375615f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,6 @@ jobs: - uses: ./.github/actions/ci-setup with: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - # @dunky.dev/state-machine ships the engine's wasm (packages/core/wasm). - - uses: ./.github/actions/wasm-setup - name: Create release PR or publish id: changesets diff --git a/.gitignore b/.gitignore index 8efd014..62c39b7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,6 @@ dist # Rust /target -# wasm-bindgen output (built by `pnpm build:wasm`) -packages/core/wasm -sandbox/shared/rust/pkg -benchmark/rust/pkg # Expo prebuild output (`expo prebuild` / `expo run:*` in the RN sandbox) sandbox/native/ios diff --git a/.oxlintrc.json b/.oxlintrc.json index ba914b6..f1dd1ef 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,10 +45,5 @@ } }, "globals": {}, - "ignorePatterns": [ - "**/dist/**", - "website/src/snippets/**", - "packages/core/wasm/**", - "**/rust/pkg/**" - ] + "ignorePatterns": ["**/dist/**", "website/src/snippets/**"] } diff --git a/AGENTS.md b/AGENTS.md index 6876572..5955dcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,11 @@ The working contract for anyone — human or agent — modifying code in this repo. This file is the canonical entry point: read it first, every time. This is Dunky's state-machine monorepo: UI behavior authored once as -state machines — in TypeScript (`packages/core`) or Rust (`crates/core`) — -running on one Rust engine, rendered anywhere through thin per-substrate -targets (`react`, `solid`, `native`, `opentui`), with a benchmark suite, -per-substrate sandboxes, and the docs website alongside. +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. The same machines also exist in Rust (`crates/core`): a second +engine that follows the same spec, for Rust programs and native bindings. ## Preflight @@ -24,13 +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 | -| Crates | `crates/**` | The Rust engine and its bindings (wasm for JS, JSI for React Native) | -| 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 suite comparing against competitor libraries | +| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | +| Website | `website/` | The docs site | +| Crates | `crates/**` | The Rust engine (`core`, `macros`), 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 @@ -42,10 +43,9 @@ These are invariants, not preferences. Violating them breaks the layered model: - **Core never imports a substrate.** No React, no React Native, no - DOM, no `window`, no `document`. `packages/core/*` is TypeScript over - the Rust engine (`crates/core`, compiled to wasm), and the engine is - just as substrate-free. If you reach for a substrate API in `core/`, - stop — the code belongs in a target. + DOM, no `window`, no `document`. `packages/core/*` is pure + TypeScript. If you reach for a substrate API in `core/`, stop — the + code belongs in a target. - **Targets never reimplement state.** Targets read from the machine via its connector. They do not fork the state graph, mirror context, or shadow transitions. If a target needs new state, the state goes in @@ -56,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` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d6fdc35..2f0ddb9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,71 +4,70 @@ For _rules_ (do this, never do that), see [`AGENTS.md`](./AGENTS.md). ## The big picture -A component's behavior is a **state machine** that knows nothing about where it -renders. You write the machine in TypeScript or in Rust; both run on **one engine**, -written in Rust. A thin per-target layer plugs the machine into a runtime: same machine, -same behavior, same accessibility intent, different render. +A component's behavior is a **state machine** — plain TypeScript that knows +nothing about where it renders. A thin per-target layer plugs that machine into a +runtime: same machine, same behavior, same accessibility intent, different render. ``` - machine written in TypeScript machine written in Rust - (a config) (dunky-core types) - | | - v v +The host +----------------------------------------------------------------------+ -| the engine: crates/core (dunky-core), Rust | -| runs the graph: queue, transitions, effects, timers, watchers, | -| computed bookkeeping | +| packages/core | +| No runtime render — pure JS, runs anywhere | +| states, events, context, guards, actions, effects, select | +----------------------------------------------------------------------+ - | engine bindings: crates/wasm (JS, as wasm), - | crates/uniffi (React Native, over JSI) - v -+----------------------------------------------------------------------+ -| packages/core (@dunky.dev/state-machine) | -| the TS API and the Machine facade: machine(), fromWasm(), | -| connector, compose, createStore | -+----------------------------------------------------------------------+ - | consumed by every target - v -+----------------------------------------------------------------------+ -| shared/bindings - substrate-agnostic event + attr vocabulary | -| shared/utils - cross-target helpers (mergeProps, ...) | -+----------------------------------------------------------------------+ - | bridged per target - v -+----------------------------------------------------------------------+ -| (react, solid, native, opentui, ...) | -| lifecycle (build + start/stop), normalize bindings -> props, | -| selector subscription | -+----------------------------------------------------------------------+ - | imported as a normal package - v - consumer app + | consumed by every target + v ++-----------------------------------------------------------------------+ +| shared/bindings | +| Substrate-agnostic event + attr vocabulary (onPress, role, …) | ++-----------------------------------------------------------------------+ + | ++-----------------------------------------------------------------------+ +| shared/utils | +| Cross-target helpers (mergeProps, composeHandlers) | ++-----------------------------------------------------------------------+ + | bridged per target + v ++------------------------------------------------------------------------+ +| (react, solid, native, opentui, …) | +| Runtime-specific bridge | +| • lifecycle (build + start/stop) • normalize bindings -> props | +| • selector subscription | ++------------------------------------------------------------------------+ + | imported as a normal package + v + Consumer app ``` This repo is the **engine** — the agnostic machine plus its per-target bridges. The components that consume it (and their style/codegen pipeline) live elsewhere. -> **Status: experimental, but it compiles and runs.** The suite is green and `tsc` -> is clean. It's an in-progress exploration — the API may still move. +> **Status: experimental, but it compiles and runs.** The `packages/core` engine +> is a stable plain-mutation kernel (no signals; see +> [`packages/core/README.md`](./packages/core/README.md)). The +> suite is green and `tsc` is clean. It's an in-progress exploration — the API may +> still move — not a 1.0. -## Layers +## Three layers -| Layer | Where | Job | -| -------------- | ------------------------------------ | ----------------------------------------------------------------------- | -| Engine | `crates/core` | What every machine does: the behavior contract, in Rust | -| Engine binding | `crates/wasm`, `crates/uniffi` | Connects the engine to a runtime: JS through wasm, React Native via JSI | -| Core | `packages/core` | The TS API every consumer imports, and the `Machine` facade | -| Shared | `packages/shared/bindings`, `/utils` | The vocabulary and helpers every target shares | -| Target | `packages/` | One substrate: lifecycle bridge, props translator, selector | +The repo splits in three layers. **`core/`** is the agnostic side — pure JS, +no renderer. It says _what_ behavior is: states, transitions, context, guards, +actions. Nothing in `core/` knows that React or the DOM exists. -Nothing below a layer knows the layer above it. The engine knows no runtime; core knows -no renderer; a target knows one substrate and nothing about the engine. +**`shared/`** is the cross-target side — `shared/bindings` owns the +substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils` +owns cross-target helpers (mergeProps, composeHandlers). + +**`/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any +future renderer. Each target is the runtime bridge for one environment: the +lifecycle bridge, the event normalization, and the selector subscription all +live here. ## The core rule: the machine never sees props A machine is pure behavior — states, transitions, context, effects. It does **not** read the consumer's props. Props are where the environment leaks in (a -DOM event handed to `onOpenChange`, a platform timer, a runtime-specific callback); +DOM event handed to `onOpenChange`, a platform timer, a host-specific callback); if the machine read them, it would be coupled to the shape one runtime happens to give it. @@ -94,38 +93,31 @@ Zag, whose machines read props directly.) ## Project structure -| Location | What it owns | -| --------------------------- | ---------------------------------------------------------------------- | -| `crates/core/` | The engine (`dunky-core`): runtime, typed authoring, binding API | -| `crates/macros/` | The derives for Rust machines (`State`, `Event`, `Context`) | -| `crates/wasm/` | The JS engine binding (`dunky-wasm`): protocol, runtime, export macro | -| `crates/core-wasm/` | The wasm `packages/core` ships (built into `packages/core/wasm`) | -| `crates/uniffi/` | The React Native engine binding, over JSI (in progress) | -| `packages/core/` | The TS API: `machine()`, `fromWasm()`, connector, the `Machine` facade | -| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | -| `packages/shared/utils/` | mergeProps, composeHandlers | -| `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | -| `sandbox/` | One demo app per substrate; `sandbox/shared/rust`: machines in Rust | -| `benchmark/` | The perf suite; `benchmark/rust`: the Rust twins of its machines | - -Each package's definition is its `SPEC.md`; the README is the quick reference. +| File / location | What it owns | +| --------------------------- | ----------------------------------------------------------------- | +| `packages/core/` | State-machine engine (plain-mutation kernel) | +| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | +| `packages/shared/utils/` | mergeProps, composeHandlers | +| `packages//` | 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/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 ``` -packages/core the TS API over the engine — no React, +core agnostic state-machine engine — no React, | no DOM, no RN (one file per concern; | public surface in index) -+-- machine() compiles a config, builds a stopped TS -| machine: .start()/.stop()/.send()/.select -+-- fromWasm() wraps a Rust machine in the same facade ++-- machine() builds a stopped service from a config; +| .start()/.stop()/.send()/.state/.select +-- context / state one plain-object context per machine | (mutated in place) + flat states +-- guards / actions and/or/not combinators · oneOf +-- connector connect() -> live, subscribable snapshot +-- compose run several machines as one (orthogonal | regions): start/stop + sync + combine -+-- createStore a reactive cell shared between machines shared/bindings substrate-agnostic event + attr vocabulary +-- (onPress, role, aria-*, …) consumed by every target's normalize @@ -140,6 +132,36 @@ shared/utils cross-target, cross-component helpers +-- normalize bindings -> target props ``` +Three package groups, three jobs: + +- **`core/`** — _the agnostic side_. Behavior, types, and the engine that + knows nothing about a renderer. +- **`shared/`** — _the cross-target side_. `shared/bindings` owns the + event + attr vocabulary; `shared/utils` owns agnostic helpers (prop + merging, handler composition). +- **`/`** — _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`). + ## The machine parts `machine.ts` is the state graph (states, transitions, action impls). It @@ -174,122 +196,16 @@ whether it needs props/platform or not: platform listener is per-target. On accept it `send()`s a plain event the machine already understands. -## The engine - -The engine is `crates/core` (`dunky-core`). It runs the graph — the run-to-completion -queue, transition resolution, entry/exit order, the effects lifecycle, `after` timers, -watchers and computed bookkeeping — and implements -[`packages/core/SPEC.md`](packages/core/SPEC.md); its Rust tests are ported from -`packages/core/tests`. It owns no clock and no IO: a timer is a command its host runs. -What is specific to the crate is [`crates/core/SPEC.md`](crates/core/SPEC.md). - -A machine is written in TypeScript or in Rust; both run on the engine: - -``` -TS machine: machine(config) Rust machine: dunky-core types - | | - | compiled: numbers + JS tables | export_machine! (your crate) - v v -crates/core-wasm (inlined in the package) your wasm module - | | - +------------------+-------------------+ - | the protocol (crates/wasm): calls with status codes, - | a notify per change, timers on the host clock - v - packages/core: the Machine facade (machine() / fromWasm()) - | - v - the targets: react, solid, native, opentui -``` - -### A machine written in TypeScript - -- `machine(config)` **compiles** the config once (cached per config object): states and - event types become numbers; guards, actions, effects, delays and computed definitions - become JS tables. The engine gets the numbers; the user code stays in JS. -- The engine runs the graph and calls back into the user code by index, through the - **host functions**. The **context** stays a plain JS object, so a read is a property - read. -- Each callback crossing is batched: a run of actions is one call, a guard walk is one - call, and — when the machine has no computed values — a whole leaving transition - (cleanups, exit, actions, the switch and its notification, entry, effects) is one call. -- JS announces its own context writes to the subscribers. The engine hears about a write - only when watchers or computed values need it. JS keeps each computed value with what - it read: a value whose inputs did not change comes from that cache, with no call. -- The wasm (`crates/core-wasm`) is inlined in the package and starts synchronously on the - first `machine()` call. - -### A machine written in Rust - -- Typed Rust (`#[derive(State, Event, Context)]`, a `Config` built once), exported to JS - with `dunky_wasm::export_machine!` in your own wasm module, and wrapped with `fromWasm`. -- Its **TS types** come from its Rust types: the build generates them onto the exported - class, and `fromWasm` infers them, so nobody writes them twice. -- The engine runs everything, user code included, inside wasm. The facade keeps a JS - **mirror** of the context and re-reads only the fields each notify names; a computed - value crosses only when it changed. -- In JS a TS machine is faster (its context already lives in JS); a Rust machine is for - reuse — the same machine runs in Rust programs, and over JSI on React Native. - -### One facade, one protocol - -- Both kinds share the **facade** (`MachineClass` in `packages/core`): it maps names to - numbers, holds the context (or its mirror), and fans each change out to the - subscribers. Targets, the connector and `compose` see only the `Machine` interface. -- The **protocol** ([`crates/wasm/SPEC.md`](crates/wasm/SPEC.md)): a call carries only - numbers (and the JS event). JS keeps the facade whose call is in progress, and the - host functions run for it. Rust holds no JS object of a machine, so only JS - references keep a machine alive — a pending timer holds its machine, like a JS timer - holds `this`. -- **Errors** stay in JS: a host function catches what user code throws, keeps it on the - facade, and returns a failure status; the engine raises its halt flag and stops the - step; the facade rethrows the error to the caller. - -### React Native - -Hermes has no WebAssembly. `crates/uniffi` is the engine binding for React Native over -JSI (in progress); until it lands, the package does not run on React Native. - ## Vocabulary -### The repo - -| Term | What it is | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **engine** | `crates/core` (`dunky-core`): the Rust runtime every machine runs on. It owns no clock and no IO. | -| **engine binding** | A crate that connects the engine to a runtime: `crates/wasm` (JS, through wasm), `crates/uniffi` (React Native, over JSI). | -| **core** | `packages/core` (`@dunky.dev/state-machine`): the TS API every consumer imports, and the facade. | -| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `solid`, `native`, `opentui`, …). | -| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. | -| **connect** | A function returning the logical surface a view spreads onto elements. | -| **compose** | Run several machines as one unit (orthogonal regions): bundled `start`/`stop` + `sync` + `combine`. | - -### Machines (core, TS) - -| Term | What it is | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **config** | A machine written in TypeScript: the state graph and its user code, a plain object. `machine()` builds a service from it. | -| **compiled config** | A config compiled once for the engine: numbers for states and event types, JS tables for the user code, and the engine's own copy of the graph. | -| **TS machine** | A service built from a config. The engine runs the graph; its user code and its context stay in JS. | -| **Rust machine** | An instance of a class exported with `export_machine!`, wrapped with `fromWasm`. The engine runs it all; JS keeps a mirror of its context. | -| **facade** | The `Machine` object JS code holds, for both kinds: names to numbers, the context (or its mirror), the subscribers. Targets only ever see a facade. | -| **source** | What a target's lifecycle hook builds a service from: a config, or a ready facade. | -| **host functions** | The JS functions the engine calls back into (`HOST` in `packages/core`): user code by index, notify, timers. They take numbers and return status codes. | -| **status code** | What every call into the engine returns: 0 done, 1 user code failed (the facade holds the error), 3 the engine failed (`takeFailure`). | - -### The engine (Rust) - -| Term | What it is | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **typed machine** | A machine written in Rust: its states, events and context are Rust types, its config is built once with a builder and shared by every instance. | -| **host** | The `Host` trait (cargo feature `host`): what an engine binding implements to run a machine's external parts and hear its changes. In JS, the host is the set of host functions. | -| **external part** | A guard, action, effect, delay or computed definition referenced by number, run by the host — the user code of a TS machine. | -| **halt flag** | Shared by the engine and its host: raised when a callback fails, it stops the current step at the next checkpoint. The binding lowers it once it has reported the failure. | -| **notify** | One per effective change, in order, with the state and the changed fields. A host may hold one back to deliver it with its next callback, no later than when the transition ends. | -| **whole transition** | A leaving transition the host runs in one call: effect cleanups, exit actions, actions, the switch and its notification, entry actions, effects. Only where JS cannot see the switch. | -| **command** | What a timer is to the engine: start or cancel, with an id. The host runs it on its clock and reports a due timer. | -| **change mask** | A bit per context field (64 at most), plus the state: what a write or a step changed. | -| **TS types** | A Rust machine's TypeScript types, generated from its Rust types (`TsType`) onto its exported class; `fromWasm` infers them. | +| Term | What it is | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **host** | The agnostic core — `packages/core/*`. Declares what behavior is. | +| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `solid`, `native`, `opentui`, …). | +| **machine** | A state-graph config consumed by `machine()`; returns a startable service. | +| **connect** | A function returning the logical surface a view spreads onto elements. | +| **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. | +| **compose** | Run several machines as one unit (orthogonal regions): bundled `start`/`stop` + `sync` + `combine`. | ## Versioning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa6a4a5..c2a5e2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,35 +13,30 @@ corepack enable pnpm install ``` -The engine is Rust (`crates/`), so every workspace task needs a Rust toolchain: the -core package loads the generated `packages/core/wasm`, and the sandbox and benchmark -load their own Rust machines. Build them once after cloning (and after changing a -crate): - -```bash -rustup target add wasm32-unknown-unknown -cargo install wasm-bindgen-cli --version "$(grep -A1 'name = "wasm-bindgen"' Cargo.lock | sed -n 's/version = "\(.*\)"/\1/p')" -brew install binaryen # optional: wasm-opt, the size pass -pnpm build:wasm -``` - ## Commands -| Command | What it does | -| ------------------------------ | ------------------------------------------------------------------------ | -| `pnpm test` | Full test suite, watch mode | -| `pnpm test:ci` | Full test suite, once | -| `pnpm typecheck` | `tsc -b` across the whole workspace | -| `pnpm lint` | `oxlint` | -| `pnpm format` / `format:check` | `oxfmt` | -| `pnpm build` | Build every publishable package | -| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | -| `pnpm build:wasm` | Build the engine and the Rust machines to wasm (`packages/core/wasm`, …) | -| `pnpm test:rust` | `cargo test` across the Rust workspace | -| `pnpm lint:rust` | `cargo clippy` (warnings are errors) + `cargo fmt --check` | +| Command | What it does | +| ------------------------------ | ---------------------------------------------------------- | +| `pnpm test` | Full test suite, watch mode | +| `pnpm test:ci` | Full test suite, once | +| `pnpm typecheck` | `tsc -b` across the whole workspace | +| `pnpm lint` | `oxlint` | +| `pnpm format` / `format:check` | `oxfmt` | +| `pnpm build` | Build every publishable package | +| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | 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` | + ## Sandbox Each sandbox renders the same command-palette machine on a different @@ -55,10 +50,6 @@ pnpm -C sandbox/opentui dev # terminal — needs Bun pnpm -C sandbox/native dev # Expo — needs a simulator or device ``` -The React and Solid sandboxes run the palette written in Rust with `?machine=rust`; -the opentui one with `pnpm -C sandbox/opentui dev:rust`. Build the wasm first -(`pnpm build:wasm`). - ## Filing an issue A bug report is only as useful as its reproduction. Use an **SSCCE** — Short, diff --git a/Cargo.lock b/Cargo.lock index 97da357..608bcc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,29 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "dunky-benchmark" -version = "0.1.0" -dependencies = [ - "dunky-core", - "dunky-sandbox", - "dunky-wasm", - "serde", - "wasm-bindgen", -] - [[package]] name = "dunky-core" version = "0.1.0" @@ -34,14 +11,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "dunky-core-wasm" -version = "0.1.0" -dependencies = [ - "dunky-wasm", - "wasm-bindgen", -] - [[package]] name = "dunky-macros" version = "0.1.0" @@ -56,45 +25,8 @@ name = "dunky-sandbox" version = "0.1.0" dependencies = [ "dunky-core", - "dunky-wasm", "serde", "serde_json", - "wasm-bindgen", -] - -[[package]] -name = "dunky-wasm" -version = "0.1.0" -dependencies = [ - "dunky-core", - "js-sys", - "serde", - "serde-wasm-bindgen", - "wasm-bindgen", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", ] [[package]] @@ -103,35 +35,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - [[package]] name = "proc-macro2" version = "1.0.107" @@ -150,12 +59,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - [[package]] name = "serde" version = "1.0.229" @@ -166,17 +69,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde_core" version = "1.0.229" @@ -210,12 +102,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "syn" version = "2.0.119" @@ -244,51 +130,6 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" -[[package]] -name = "wasm-bindgen" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" -dependencies = [ - "unicode-ident", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 19feb81..dc5b42c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,9 @@ -# The Rust engine, its bindings, and the machines the sandbox and the benchmark -# write in Rust. Members are listed explicitly so a half-built crate -# never breaks `cargo test --workspace` for the others. +# The Rust engine: its own implementation of packages/core/SPEC.md, next to the TS one. +# Members are listed explicitly so a half-built crate never breaks +# `cargo test --workspace` for the others. [workspace] resolver = "3" -members = [ - "crates/core", - "crates/macros", - "crates/wasm", - "crates/core-wasm", - "sandbox/shared/rust", - "benchmark/rust", -] +members = ["crates/core", "crates/macros", "crates/sandbox"] # Built by its own pipeline (uniffi-bindgen-react-native) for iOS/Android. exclude = ["crates/uniffi"] @@ -24,23 +17,12 @@ rust-version = "1.85" [workspace.dependencies] dunky-core = { path = "crates/core" } dunky-macros = { path = "crates/macros" } -dunky-wasm = { path = "crates/wasm" } -dunky-sandbox = { path = "sandbox/shared/rust" } +dunky-sandbox = { path = "crates/sandbox" } serde = { version = "1", features = ["derive"] } serde_json = "1" -# Must match the installed wasm-bindgen CLI exactly (wasm-bindgen --version). -wasm-bindgen = "=0.2.129" -js-sys = "0.3" -serde-wasm-bindgen = "0.6" [profile.release] opt-level = 3 lto = true codegen-units = 1 panic = "abort" - -# Size-tuned profile for the shipped .wasm. -[profile.wasm] -inherits = "release" -opt-level = "z" -strip = true diff --git a/README.md b/README.md index 585fe20..866a032 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,9 @@ **Define a behavior once. Render it anywhere.** A UI component is really two things tangled together: _behavior_ and _render_. -Dunky splits them. You describe behavior as a **state machine** — in TypeScript or -in Rust — that knows nothing about the environment, and a thin per-substrate layer -plugs it into a runtime. Both run on one engine, written in Rust and compiled to -WebAssembly. +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. @@ -39,13 +38,13 @@ canvas board, a game HUD. There the cost of each transition and the memory per machine, multiplied by thousands, is what decides whether you hold the frame. The engine is built for it: -| At scale (thousands of machines) | Dunky | XState | Zag | -| -------------------------------- | --------: | -----: | ------: | -| Event throughput (ops/s) | **9.9 M** | 1.6 M | n/a ᵃ | -| Memory / machine, 2-field (KB) | **3.5** | 3.6 | 8.9 | -| Memory / machine, 64-field (KB) | **4.0** | 4.1 | **134** | +| At scale (thousands of machines) | Dunky | XState | Zag | +| -------------------------------- | ---------: | -----: | ------: | +| Event throughput (ops/s) | **11.6 M** | 1.6 M | n/a ᵃ | +| Memory / machine, 2-field (KB) | **3.9** | 3.6 | 8.9 | +| Memory / machine, 64-field (KB) | **4.4** | 4.1 | **134** | -→ **~6× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the +→ **~7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the **[benchmark README](./benchmark/README.md)**. **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. @@ -57,12 +56,10 @@ 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) -- **engine** — the state-machine runtime, in Rust (`crates/core`). Pure behavior: - states, transitions, context, effects. Knows nothing about a renderer, and runs - machines written in TypeScript or in Rust. -- **core** — the TS API over the engine (`@dunky.dev/state-machine`): `machine()` - for machines written in TS, `fromWasm()` for machines written in Rust, and the - `Machine` object every target consumes. +- **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. - **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 @@ -74,9 +71,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, the layered model, and the vocabulary. -- **[`packages/core/README.md`](./packages/core/README.md)** — the TS API, its quick reference; [`SPEC.md`](./packages/core/SPEC.md) defines it. -- **[`crates/core/README.md`](./crates/core/README.md)** — the Rust engine and writing machines in Rust; [`SPEC.md`](./crates/core/SPEC.md) defines it. +- **[`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. - **[`benchmark/README.md`](./benchmark/README.md)** — what's measured, the methodology, and results vs. XState & Zag. - **[`ACCESSIBILITY.md`](./ACCESSIBILITY.md)** — the external specs every package answers to. - **[`AGENTS.md`](./AGENTS.md)** — the contributor / agent contract. diff --git a/benchmark/README.md b/benchmark/README.md index 6506c75..f0fe616 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -4,11 +4,9 @@ A performance harness for `@dunky.dev/state-machine`. It measures the engine's hot paths in isolation and compares the runnable parts against [XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). -Numbers below are the average of two runs (Node 24, Apple M5 Pro). The engine is Rust, compiled to -wasm: the **Dunky** rows run machines written in TypeScript on it, and -[section 8](#8-ts-machines-vs-rust-machines-testswasmts) compares the same machines -written in Rust. Absolute figures vary by machine, Node version, and thermal state — -**run it yourself** — but the ranking and the scaling shape are what hold. +Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures +vary by machine, Node version, and thermal state — **run it yourself** — but the +ranking and the scaling shape are what hold. > Want to _see_ it instead of read tables? **`pnpm benchmark:demo`** opens a live > side-by-side where each engine drives a grid of real per-cell machines under a @@ -97,11 +95,11 @@ XState's coarse `actor.subscribe`. | Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------- | --------------: | ---------------: | ----: | -| 100 | 659.3 K | 445.2 K | n/a ᵃ | -| 1000 | 53.5 K | 52.4 K | n/a ᵃ | -| 5000 | **19.1 K** | 1.3 K | n/a ᵃ | +| 100 | 658.5 K | 437.8 K | n/a ᵃ | +| 1000 | 56.8 K | 49.2 K | n/a ᵃ | +| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | -→ Roughly par at small N, but Dunky **~14× faster at 5000 observers** — XState's +→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's coarse subscribe degrades much faster as the observer set grows. **B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The @@ -111,23 +109,23 @@ model gets for free and a coarse bus has to work to ignore. | Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | | ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **4.8 M** | 885.7 K | n/a ᵃ | -| 5000 | **3.9 M** | 842.4 K | n/a ᵃ | +| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | +| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | -→ Dunky is **~5× faster** at shrugging off a write nobody is watching (1000 +→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 cells); the value-deduping bus skips waking observers entirely. **C. Throughput — single machine, one event.** Per-transition cost with no selection scaling — the raw `send` price. -| Single machine, one event | ops/sec | -| ------------------------- | --------: | -| Dunky | **9.9 M** | -| XState (raw) | 1.6 M | -| XState (diffed) | 1.6 M | -| Zag | n/a ᵃ | +| Single machine, one event | ops/sec | +| ------------------------- | ---------: | +| Dunky | **11.6 M** | +| XState (raw) | 1.6 M | +| XState (diffed) | 1.6 M | +| Zag | n/a ᵃ | -→ Dunky pushes **~6× the events/sec** of XState. Context is mutated in place, +→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, so a transition allocates nothing; XState builds a fresh snapshot per event. ## 2. Compose / synced machines (`tests/compose.ts`) @@ -144,12 +142,12 @@ change (the O(members) path by design). | Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | | ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 9.3 M | 9.7 M | n/a ᶠ | n/a ᶠ | -| 10 | 8.7 M | 8.9 M | n/a ᶠ | n/a ᶠ | -| 50 | 7.3 M | 7.8 M | n/a ᶠ | n/a ᶠ | +| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | +| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | +| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | -→ Cross-region coordination stays in the **~7.3–9.7 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~20–22% going from 2 to 50. +→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. > A third "chain" sub-test (a sync rule that `send()`s downstream every change) was > removed: under a tight loop it shows superlinear slowdown. That's a real @@ -165,13 +163,13 @@ profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Z | Scenario | Dunky (ops/sec) | XState | Zag | | ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **56.2 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 8.6 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 4.7 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 2.3 M | n/a ᶠ | n/a ᵃ | +| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | -→ A cached read is **~56.2 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~8.6 M/sec — read-key tracking means +→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means you only pay the recompute when an input you actually read changes. ## 4. Engine hot paths (`tests/engine.ts`) @@ -183,18 +181,17 @@ Zag's `send` is async (**n/a ᵃ**). | Scenario | Dunky (ops/sec) | XState | Zag | | -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 4.8 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.6 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 4.2 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 8.4 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 7.3 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 10.4 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 7.6 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~4.2–10.4 M ops/sec**: a 32-candidate guard walk -(evaluated in one call from the engine into JS), full state transitions with -entry/exit actions, and effect boot/cleanup every transition all stay within -~2.4× of a bare `send`. +| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full +state transitions with entry/exit actions, and effect boot/cleanup every +transition all stay in the same order of magnitude as a bare `send`. ## 5. Construction cost (`tests/construct.ts`) @@ -204,40 +201,40 @@ warmed first. | Build + start | Dunky (µs/machine) | XState | Zag | | ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.36 | 1.07 | 5.02 | +| 10 000 | 1.58 | 1.35 | 5.06 | → Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.3× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.7× faster than Zag's per-field reactive cells. +~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.2× faster than Zag's per-field reactive cells. ## 6. Memory per machine (`tests/memory.ts`) Build 5000 machines, hold them live, report retained heap per machine (`heapMB()` -double-GCs before sampling; for Dunky, the JS heap plus the engine's wasm heap). Two context widths — **thin** (2 fields) and **fat** +double-GCs before sampling). Two context widths — **thin** (2 fields) and **fat** (64 fields) — because the whole point of the plain-object model is that memory stays ~flat in field count. Rows below are the **written** mode (one `hit` each — the footprint a churny app actually pays). | Context | Dunky (KB/machine) | XState | Zag | | -------- | -----------------: | -----: | ------: | -| 2-field | 3.46 | 3.62 | 8.92 | -| 64-field | 3.99 | 4.10 | **134** | +| 2-field | 3.85 | 3.61 | 8.92 | +| 64-field | 4.35 | 4.10 | **134** | → Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with the data you store, not with a per-field cell. **Zag is the contrast**: one reactive -cell per field balloons the 64-field context to ~134 KB/machine — **~34× more** than +cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than Dunky. **Idle vs written.** Dunky owns its context copy from construction and mutates -it in place forever, so its idle and written footprints nearly match by design — -while a lazy-copy scheme steps up once writes start: +it in place forever, so its idle and written footprints match by design — while a +lazy-copy scheme steps up once writes start: | 64-field, 5000 machines | Dunky | XState | Zag | | ----------------------- | ----: | -----: | --: | -| Idle (never written) | 3.96 | 3.55 | 130 | -| Written (1 event each) | 3.99 | 4.10 | 134 | +| Idle (never written) | 4.35 | 3.55 | 130 | +| Written (1 event each) | 4.35 | 4.10 | 134 | -→ Dunky idle ≈ written; XState's first `assign` allocates a per-actor context, +→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, so its written row grows. ## 7. React rendering (`tests/rendering/`) @@ -255,56 +252,19 @@ List of 1000 rows: | -------------------- | ----------------: | ---------: | ------------------: | | Dunky/instance | **2** | 3.6 | **2.4** | | Dunky/selector | 2 | 4.9 | 3.8 | -| xstate/selector | 2 | 3.6 | 4.4 | -| zag/instance | 2 | 4.2 | n/a ᵃ | -| naive (anti-pattern) | **980** | 4.9 | 34.9 | +| xstate/selector | 2 | 3.7 | 4.3 | +| zag/instance | 2 | 3.9 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.5 | 38.3 | → Every properly-set-up engine wakes only the **2** rows that changed (vs. the naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~15× the wall time). Among the surgical strategies Dunky re-renders **~1.8× +~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× faster than XState**. Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the microtask-batched `send` can't be timed under a synchronous `flushSync` loop, so only its row-count is comparable. -## 8. TS machines vs Rust machines (`tests/wasm.ts`) - -Every machine runs on the Rust engine, but you can write it in TypeScript (a config) -or in Rust (`dunky-core`, wrapped with `fromWasm`). Each row runs the **same machine** -both ways — `benchmark/rust` ports the TS machines 1:1. **Rust, raw** is the bare -wasm handle with no facade: the floor. A call into wasm costs the same as a JS call -(~18 ns), so the gaps are the work, not the boundary. - -| Scenario | TS machine (ops/sec) | Rust machine | Rust, raw | -| ----------------------------------------- | -------------------: | -----------: | --------: | -| One event, observed field | **10.7 M** | 8.5 M | 15.5 M | -| One event, unobserved field | **10.8 M** | 8.1 M | 14.9 M | -| State churn — exit+entry every event | **10.4 M** | 8.0 M | 9.8 M | -| Guard fallthrough — 8 candidates | 8.9 M | **9.7 M** | — | -| Palette — type + read, 11 commands | **881.6 K** | 433.9 K | — | -| Palette — type + read, 1,000 commands | **13.1 K** | 8.2 K | — | -| Palette — type + read, 10,000 commands | **1.3 K** | 819 | — | -| Construct + start one machine (µs / mach) | 4.54 | **4.30** | 2.30 | - -→ In JavaScript, a machine written in TS handles **~1.3× the events** of its Rust -twin. Its context already lives in JS, while a Rust machine pushes each change back -across the boundary: a notify, then a read per changed field to refresh the JS -context mirror. The raw handle (no subscribers, no mirror) shows the engine itself: -~15 M events/sec. A guard walk is the exception: there the Rust machine is ~1.1× -faster, since its guards never leave wasm. - -→ Heavy derived data favors TS too: the palette filters **~1.6–2.0× faster** as a TS -machine, because its filter runs as JIT-compiled JS and its results are already JS -values, while the Rust machine serializes its results on each change. Crossing as -indices mapped back onto your own objects (the rows above) beats crossing whole -objects by ~22% (6.7 K at 1,000 commands). - -→ Rust machines are for reuse: the same machine runs in a Rust program and over JSI, -where there is no JS mirror to keep. In React ([section 7](#7-react-rendering-testsrendering)) -both render alike: a Rust machine per row re-renders the 1000-row list in **2.4 ms** -(a TS machine: 2.5 ms), and one behind `useSelector` in 3.6 ms (TS: 3.8 ms). - ## Where this actually matters The loads here (and in the demo) are deliberately extreme to force the engines to diff --git a/benchmark/index.ts b/benchmark/index.ts index de5c655..05db838 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -21,7 +21,6 @@ import { runEngine } from './tests/engine' import { runConstruct } from './tests/construct' import { runMemory } from './tests/memory' import { runRendering } from './tests/rendering/run' -import { WASM_SKIPPED, wasmBuilt } from './wasm-built' async function main() { console.log('Benchmark suite (disposable). Node', process.version) @@ -34,9 +33,6 @@ async function main() { await runEngine() await runConstruct() await runMemory() - // The Rust/wasm comparison loads the built wasm, so it is imported only when it exists. - if (wasmBuilt()) await (await import('./tests/wasm')).runWasm() - else console.warn(`\n${WASM_SKIPPED}`) await runRendering() // last: imports react-dom (bootstraps its own jsdom) console.log('\n========== DONE ==========') } diff --git a/benchmark/package.json b/benchmark/package.json index d4a8d29..eae77d5 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -8,8 +8,7 @@ }, "dependencies": { "@dunky.dev/react-state-machine": "workspace:^", - "@dunky.dev/state-machine": "workspace:^", - "@sandbox/cmdk-core": "workspace:*" + "@dunky.dev/state-machine": "workspace:^" }, "devDependencies": { "@types/jsdom": "^21.1.7", diff --git a/benchmark/rust.ts b/benchmark/rust.ts deleted file mode 100644 index fcd4d30..0000000 --- a/benchmark/rust.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * The benchmark machines written in Rust (benchmark/rust), wrapped with `fromWasm` — each - * one the twin of a TS machine in this suite. Needs the wasm build (`pnpm build:wasm`); - * `loadRust()` before building machines. - */ -import { readFileSync } from 'node:fs' -import { fromWasm, type Machine } from '@dunky.dev/state-machine' -import type { - Command, - CommandPaletteComputed, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteState, -} from '@sandbox/cmdk-core' -import { - CellMachine, - GuardsMachine, - HighlightMachine, - initSync, - ListMachine, - noop, - PaletteMachine, - PingPongMachine, - sink, -} from './rust/pkg/dunky_benchmark.js' - -export { noop, sink } - -export const WASM_URL = new URL('./rust/pkg/dunky_benchmark_bg.wasm', import.meta.url) - -let loaded = false -/** Load the wasm synchronously (idempotent). */ -export function loadRust(): void { - if (loaded) return - initSync({ module: readFileSync(WASM_URL) }) - loaded = true -} - -type Palette = Machine< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed -> - -/** The command palette; `results` crosses as indices, mapped onto the caller's objects. */ -export function createPalette(commands: Command[]): Palette { - return fromWasm(new PaletteMachine(commands), { - computed: { - results: { - from: 'resultIndices', - map: raw => Array.from(raw as ArrayLike, i => commands[i]!), - }, - }, - }) -} - -/** The same palette, with `results` crossing as full objects (for comparison). */ -export function createPaletteObjects(commands: Command[]): Palette { - return fromWasm(new PaletteMachine(commands)) -} - -// Each machine's types come from its Rust types (generated onto the class by the build). -export function createCell() { - return fromWasm(new CellMachine()) -} - -export function createPingPong() { - return fromWasm(new PingPongMachine()) -} - -export function createGuards(k: number) { - return fromWasm(new GuardsMachine(k)) -} - -/** The rendering bench's shared list: one highlighted index. */ -export function createList() { - return fromWasm(new ListMachine()) -} - -/** The rendering bench's per-row machine: one boolean. */ -export function createHighlight(on: boolean) { - return fromWasm(new HighlightMachine(on)) -} - -/** Raw handles, for measuring the boundary without the facade. */ -export const raw = { CellMachine, PingPongMachine } diff --git a/benchmark/rust/Cargo.toml b/benchmark/rust/Cargo.toml deleted file mode 100644 index d1e1971..0000000 --- a/benchmark/rust/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "dunky-benchmark" -description = "The benchmark machines written in Rust, 1:1 twins of the TS ones, exported to wasm for the TS-vs-Rust rows." -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -dunky-core = { workspace = true } -dunky-wasm = { workspace = true } -dunky-sandbox = { workspace = true } -serde = { workspace = true } -wasm-bindgen = { workspace = true } - -[[example]] -name = "typescript" diff --git a/benchmark/rust/examples/typescript.rs b/benchmark/rust/examples/typescript.rs deleted file mode 100644 index 87e6736..0000000 --- a/benchmark/rust/examples/typescript.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Prints the TS types of the exported classes; `pnpm build:wasm` appends them to the -//! module's `.d.ts`, where `fromWasm` reads them. - -fn main() { - print!("{}", dunky_benchmark::typescript()); -} diff --git a/benchmark/rust/src/cell.rs b/benchmark/rust/src/cell.rs deleted file mode 100644 index 92d8d90..0000000 --- a/benchmark/rust/src/cell.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! The benchmark "cell": one state, two counters. `hit` changes the observed field, -//! `miss` an unobserved one (mirrors `makeCoreMachine` in benchmark/competitors.ts). - -use dunky_core::{Config, Context, Event, State, Types}; -use serde::Deserialize; - -#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] -pub enum CellState { - Idle, -} - -#[derive(Event, Clone, Debug, Deserialize)] -#[event(deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum CellEvent { - Hit, - Miss, -} - -#[derive(Context, Clone, Debug, PartialEq, Default)] -#[context(serialize)] -pub struct CellCtx { - pub value: u32, - pub other: u32, -} - -pub struct Cell; - -impl Types for Cell { - type State = CellState; - type Event = CellEvent; - type Context = CellCtx; -} - -pub fn config() -> Config { - let mut b = Config::::builder(CellState::Idle, CellCtx::default()); - b.state(CellState::Idle, |s| { - s.on(CellEventKind::Hit, |t| { - t.run(|p| { - let value = p.context().value + 1; - p.set_context(CellCtx::patch().value(value)); - }) - }) - .on(CellEventKind::Miss, |t| { - t.run(|p| { - let other = p.context().other + 1; - p.set_context(CellCtx::patch().other(other)); - }) - }) - }); - b.build() -} diff --git a/benchmark/rust/src/guards.rs b/benchmark/rust/src/guards.rs deleted file mode 100644 index 133aabb..0000000 --- a/benchmark/rust/src/guards.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Guard fallthrough: `k` guarded candidates for one event where only the LAST passes -//! (mirrors benchmark/tests/engine.ts, section A). - -use dunky_core::{Config, Context, Event, State, Types}; -use serde::Deserialize; - -use crate::bump; - -#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] -pub enum GuardsState { - Idle, -} - -#[derive(Event, Clone, Debug, Deserialize)] -#[event(deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum GuardsEvent { - Go, -} - -#[derive(Context, Clone, Debug, PartialEq)] -#[context(serialize)] -pub struct GuardsCtx { - pub pick: u32, -} - -pub struct Guards; - -impl Types for Guards { - type State = GuardsState; - type Event = GuardsEvent; - type Context = GuardsCtx; -} - -pub fn config(k: u32) -> Config { - assert!(k > 0, "at least one candidate"); - let mut b = Config::::builder(GuardsState::Idle, GuardsCtx { pick: k - 1 }); - b.state(GuardsState::Idle, |mut s| { - for i in 0..k { - s = s.on(GuardsEventKind::Go, move |t| { - t.guard_fn(move |g| g.context().pick == i).run(|_| bump()) - }); - } - s - }); - b.build() -} diff --git a/benchmark/rust/src/highlight.rs b/benchmark/rust/src/highlight.rs deleted file mode 100644 index 38beb96..0000000 --- a/benchmark/rust/src/highlight.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! The rendering bench's per-row machine: each row owns one boolean -//! (mirrors `makeCoreHighlightMachine` in benchmark/tests/rendering/bench.tsx). - -use dunky_core::{Config, Context, Event, State, Types}; -use serde::Deserialize; - -#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] -pub enum HighlightState { - Idle, -} - -#[derive(Event, Clone, Debug, Deserialize)] -#[event(deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum HighlightEvent { - Set { on: bool }, -} - -#[derive(Context, Clone, Debug, PartialEq, Default)] -#[context(serialize)] -pub struct HighlightCtx { - pub on: bool, -} - -pub struct Highlight; - -impl Types for Highlight { - type State = HighlightState; - type Event = HighlightEvent; - type Context = HighlightCtx; -} - -pub fn config() -> Config { - let mut b = Config::::builder(HighlightState::Idle, HighlightCtx::default()); - b.state(HighlightState::Idle, |s| { - s.on(HighlightEventKind::Set, |t| { - t.act(|p| match p.event() { - Some(HighlightEvent::Set { on }) => HighlightCtx::patch().on(*on), - None => HighlightCtx::patch(), - }) - }) - }); - b.build() -} diff --git a/benchmark/rust/src/lib.rs b/benchmark/rust/src/lib.rs deleted file mode 100644 index 54e3ffc..0000000 --- a/benchmark/rust/src/lib.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! The benchmark machines, written in Rust against `dunky-core` — each one the twin of a -//! TS machine in `benchmark/`, so a row compares the same work: -//! -//! - [`cell`], [`pingpong`], [`guards`] — the engine shapes. -//! - [`list`], [`highlight`] — the React rendering bench's shared list and per-row machine. -//! - The command palette comes from the sandbox (`dunky_sandbox::palette`). -//! -//! Built to wasm by `pnpm build:wasm` (`benchmark/rust/pkg`). - -use std::cell::Cell as StdCell; - -use dunky_core::{Config, Machine}; -use dunky_sandbox::palette; -use wasm_bindgen::prelude::*; - -pub mod cell; -pub mod guards; -pub mod highlight; -pub mod list; -pub mod pingpong; - -thread_local! { - static SINK: StdCell = const { StdCell::new(0) }; -} - -/// Entry/exit/guard actions bump the sink so the work is not optimized away. -pub fn bump() { - SINK.with(|s| s.set(s.get() + 1)); -} - -/// The sink, read once per run as proof the actions ran. -#[wasm_bindgen] -pub fn sink() -> f64 { - SINK.with(StdCell::get) as f64 -} - -/// Raw boundary cost: one number in, one number out, no work. -#[wasm_bindgen] -pub fn noop(x: u32) -> u32 { - x -} - -// One config per machine type, shared by every instance (the shape a real app has). -thread_local! { - static PALETTE: Config = palette::config(Vec::new()); - static CELL: Config = cell::config(); - static PINGPONG: Config = pingpong::config(); - static LIST: Config = list::config(); - static HIGHLIGHT: Config = highlight::config(); -} - -dunky_wasm::export_machine! { - /// The command palette. - pub struct PaletteMachine(palette::Palette); - new(commands: JsValue) { - dunky_wasm::from_js::>(commands) - .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) - } - computed { - palette::RESULTS => Vec, - palette::ACTIVE_ID => Option, - } - // Only the JS side's `results` mapping reads it: not part of the machine's types. - internal { - palette::RESULT_INDICES => Vec, - } -} - -dunky_wasm::export_machine! { - /// `hit` bumps `value`, `miss` bumps `other`. - pub struct CellMachine(cell::Cell); - new() { CELL.with(Machine::new) } - computed {} -} - -dunky_wasm::export_machine! { - /// Ping <-> pong with entry/exit actions on every transition. - pub struct PingPongMachine(pingpong::PingPong); - new() { PINGPONG.with(Machine::new) } - computed {} -} - -dunky_wasm::export_machine! { - /// `k` guarded candidates, the last one wins. - pub struct GuardsMachine(guards::Guards); - new(k: u32) { Machine::new(&guards::config(k.max(1))) } - computed {} -} - -dunky_wasm::export_machine! { - /// The rendering bench's shared list (one highlighted index). - pub struct ListMachine(list::List); - new() { LIST.with(Machine::new) } - computed {} -} - -dunky_wasm::export_machine! { - /// The rendering bench's per-row machine (one boolean). - pub struct HighlightMachine(highlight::Highlight); - new(on: bool) { - HIGHLIGHT.with(|c| Machine::with_context(c, highlight::HighlightCtx { on })) - } - computed {} -} - -/// The classes' TS types (`examples/typescript.rs`, run by `pnpm build:wasm`). The guards -/// config only sizes its candidate list, so any `k` gives the same types. -#[cfg(not(target_arch = "wasm32"))] -pub fn typescript() -> String { - [ - PALETTE.with(PaletteMachine::typescript), - CELL.with(CellMachine::typescript), - PINGPONG.with(PingPongMachine::typescript), - GuardsMachine::typescript(&guards::config(1)), - LIST.with(ListMachine::typescript), - HIGHLIGHT.with(HighlightMachine::typescript), - ] - .concat() -} diff --git a/benchmark/rust/src/list.rs b/benchmark/rust/src/list.rs deleted file mode 100644 index 78833ca..0000000 --- a/benchmark/rust/src/list.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! The rendering bench's shared list: one machine holds the highlighted row -//! (mirrors `makeListMachine` in benchmark/tests/rendering/bench.tsx). - -use dunky_core::{Config, Context, Event, State, Types}; -use serde::Deserialize; - -#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] -pub enum ListState { - Idle, -} - -#[derive(Event, Clone, Debug, Deserialize)] -#[event(deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ListEvent { - Move { to: u32 }, -} - -#[derive(Context, Clone, Debug, PartialEq, Default)] -#[context(serialize)] -pub struct ListCtx { - pub highlighted: u32, -} - -pub struct List; - -impl Types for List { - type State = ListState; - type Event = ListEvent; - type Context = ListCtx; -} - -pub fn config() -> Config { - let mut b = Config::::builder(ListState::Idle, ListCtx::default()); - b.state(ListState::Idle, |s| { - s.on(ListEventKind::Move, |t| { - t.act(|p| match p.event() { - Some(ListEvent::Move { to }) => ListCtx::patch().highlighted(*to), - None => ListCtx::patch(), - }) - }) - }); - b.build() -} diff --git a/benchmark/rust/src/pingpong.rs b/benchmark/rust/src/pingpong.rs deleted file mode 100644 index 5622af4..0000000 --- a/benchmark/rust/src/pingpong.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Real state churn: every `go` exits one state and enters the other, running exit and -//! entry actions (mirrors benchmark/tests/engine.ts, section B). - -use dunky_core::{Config, Event, State, Types}; -use serde::Deserialize; - -use crate::bump; - -#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] -pub enum PingPongState { - Ping, - Pong, -} - -#[derive(Event, Clone, Debug, Deserialize)] -#[event(deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum PingPongEvent { - Go, -} - -pub struct PingPong; - -impl Types for PingPong { - type State = PingPongState; - type Event = PingPongEvent; - type Context = (); -} - -pub fn config() -> Config { - use PingPongState::{Ping, Pong}; - let mut b = Config::::builder(Ping, ()); - b.state(Ping, |s| { - s.entry_run(|_| bump()) - .exit_run(|_| bump()) - .on(PingPongEventKind::Go, |t| t.target(Pong)) - }); - b.state(Pong, |s| { - s.entry_run(|_| bump()) - .exit_run(|_| bump()) - .on(PingPongEventKind::Go, |t| t.target(Ping)) - }); - b.build() -} diff --git a/benchmark/tests/memory.ts b/benchmark/tests/memory.ts index 6fa83aa..9de82bd 100644 --- a/benchmark/tests/memory.ts +++ b/benchmark/tests/memory.ts @@ -4,8 +4,6 @@ * The central claim — "flat memory at 5k scale, even with fat context" (the * trading-view case) — asserted in the README with no script until now. This * measures it: build N machines, hold them live, report retained heap / machine. - * For core that is the JS heap PLUS the engine's own heap: the Rust engine runs in - * wasm, whose memory a JS heap sample does not see. * * Two context widths, because the whole point of the plain-object model is that * memory should stay ~flat in FIELD COUNT (no per-field reactive cell): @@ -21,8 +19,6 @@ import { createActor, createMachine as createXMachine, assign } from 'xstate' import { createMachine as createZagMachine } from '@zag-js/core' import { VanillaMachine } from '@zag-js/vanilla' import { machine } from '@dunky.dev/state-machine' -// Not public API: the same module instance the package uses (one engine per process). -import { engineHeapBytes } from '../../packages/core/src/wasm' import { heapMB } from '../report' const FIELDS = { thin: 2, fat: 64 } as const @@ -133,8 +129,6 @@ type Sendable = { send: (e: { type: string }) => void } interface Engine { build: (fields: number) => unknown - /** Memory the JS heap does not see (MB), e.g. core's wasm engine. */ - outsideHeapMB?: () => number // Fire one `hit` so a write actually happens. Core owns its context copy from // construction (idle ≈ written by design); for XState the first assign() // allocates a per-actor context, so its written footprint grows — the @@ -145,7 +139,7 @@ interface Engine { const sendHit = (m: unknown) => (m as Sendable).send({ type: 'hit' }) const ENGINES: Record = { - core: { build: buildCore, write: sendHit, outsideHeapMB: () => engineHeapBytes() / 1024 / 1024 }, + core: { build: buildCore, write: sendHit }, xstate: { build: buildXstate, write: sendHit }, // bind a shared per-width config so we measure machine overhead, not config dup zag: { @@ -161,15 +155,14 @@ const ENGINES: Record = { } function measureOnce(engine: Engine, N: number, fields: number, write: boolean): number { - const sample = () => heapMB() + (engine.outsideHeapMB?.() ?? 0) - const before = sample() + const before = heapMB() const hold: unknown[] = Array.from({ length: N }) for (let i = 0; i < N; i++) { const m = engine.build(fields) if (write) engine.write(m) hold[i] = m } - const after = sample() + const after = heapMB() // keep `hold` reachable across the sample so it isn't collected if ((hold as unknown[]).length !== N) throw new Error('unreachable') return after - before // MB retained by the N machines diff --git a/benchmark/tests/rendering/bench.tsx b/benchmark/tests/rendering/bench.tsx index 402fc00..f89cc8a 100644 --- a/benchmark/tests/rendering/bench.tsx +++ b/benchmark/tests/rendering/bench.tsx @@ -38,8 +38,6 @@ import { import { useSelector as useXSelector } from '@xstate/react' import { machine, type Machine } from '@dunky.dev/state-machine' import { useSelector } from '@dunky.dev/react-state-machine' -import { readFileSync, writeFileSync } from 'node:fs' -import { WASM_SKIPPED, wasmBuilt } from '../../wasm-built' type Ctx = { highlighted: number } type Ev = { type: 'move'; to: number } @@ -114,24 +112,12 @@ const renderCounts = { 'core/instance': 0, 'zag/instance': 0, 'xstate/selector': 0, - // The same rows over machines written in Rust (benchmark/rust → wasm, via fromWasm). - 'wasm/selector': 0, - 'wasm/instance': 0, } -// Fine-grained row: re-renders only when ITS highlighted-ness flips. `counter` -// lets the wasm arena reuse the very same row over a Rust machine. -function SelectorRow({ - m, - index, - counter = 'selector', -}: { - m: Machine<'idle', Ctx, Ev> - index: number - counter?: 'selector' | 'wasm/selector' -}) { +// Fine-grained row: re-renders only when ITS highlighted-ness flips. +function SelectorRow({ m, index }: { m: Machine<'idle', Ctx, Ev>; index: number }) { const isHL = useSelector(m, () => m.context.highlighted === index) - renderCounts[counter]++ + renderCounts.selector++ return
{index}
} @@ -145,15 +131,9 @@ function NaiveRow({ m, index }: { m: Machine<'idle', Ctx, Ev>; index: number }) } // Per-instance core row: each owns its machine; re-renders only when its own bool flips. -function CoreInstanceRow({ - m, - counter = 'core/instance', -}: { - m: ReturnType - counter?: 'core/instance' | 'wasm/instance' -}) { +function CoreInstanceRow({ m }: { m: ReturnType }) { const on = useSelector(m, () => m.context.on) - renderCounts[counter]++ + renderCounts['core/instance']++ return
} @@ -178,7 +158,7 @@ function XStateSelectorRow({ actor, index }: { actor: XActor; index: number }) { return
{index}
} -type Strategy = keyof typeof renderCounts +type Strategy = 'selector' | 'naive' | 'core/instance' | 'zag/instance' | 'xstate/selector' type Row = { mount: number; mountMs: number; renders: number; ms: number } export async function runRenderingBench(N: number, moves: number) { @@ -318,51 +298,6 @@ export async function runRenderingBench(N: number, moves: number) { actor.stop() } - // --- The same two arenas over the Rust core (wasm), when it is built --- - const rust = wasmBuilt() ? await import('../../rust') : null - if (!rust) console.warn(WASM_SKIPPED) - if (rust) { - rust.loadRust() // idempotent; the suite's wasm section already loaded it - const { createHighlight, createList } = rust - const m = createList() - m.start() - results['wasm/selector'] = run( - 'wasm/selector', - () => - Array.from({ length: N }, (_, i) => ( - - )), - k => m.send({ type: 'move', to: k % N }), - ) - m.stop() - - const ms_ = Array.from({ length: N }, (_, i) => createHighlight(i === 0)) - ms_.forEach(m => m.start()) - let cur = 0 - results['wasm/instance'] = run( - 'wasm/instance', - () => ms_.map((m, i) => ), - k => { - const next = k % N - ms_[cur].send({ type: 'set', on: false }) - ms_[next].send({ type: 'set', on: true }) - cur = next - }, - ) - ms_.forEach(m => m.stop()) - } - - // SPIKE_RENDER_OUT=: also write these rows as JSON (the spike report). - if (process.env.SPIKE_RENDER_OUT) { - const file = process.env.SPIKE_RENDER_OUT - let prev: Record = {} - try { - prev = JSON.parse(readFileSync(file, 'utf8')) as Record - } catch {} - prev[String(N)] = results - writeFileSync(file, JSON.stringify(prev, null, 2)) - } - console.log(`\n### Rendering — list of ${N}: first render (mount) + ${moves} highlight moves`) // NOTE on the `mount (ms)` column: it is NOT apples-to-apples across rows — it // reflects each library's idiomatic per-row primitive, which is the only fair diff --git a/benchmark/tests/wasm.ts b/benchmark/tests/wasm.ts deleted file mode 100644 index 87b97cd..0000000 --- a/benchmark/tests/wasm.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * A machine written in TS vs the same machine written in Rust — both on the Rust engine. - * - * Every Rust row runs the SAME machine as its TS row (benchmark/rust ports them 1:1): - * - * ts machine — `machine(config)`: the engine (wasm) calls back into the TS user code. - * rust machine — through `fromWasm`: the `Machine` interface over a Rust machine in its - * own wasm module. This is what a React/Solid target actually calls. - * rust raw — the bare wasm-bindgen handle, no facade: the boundary floor. - * - * Needs the wasm build (`pnpm build:wasm`). Set SPIKE_OUT= to also write the - * numbers as JSON (the spike report reads them). - * - * Exported as `runWasm()`; the suite runs it via benchmark/index.ts. - */ -import { readFileSync, writeFileSync } from 'node:fs' -import { performance } from 'node:perf_hooks' -import { Bench } from 'tinybench' -import { machine } from '@dunky.dev/state-machine' -import { commandPaletteMachineConfig, type Command } from '@sandbox/cmdk-core' -import { - createCell, - createGuards, - createPalette, - createPaletteObjects, - createPingPong, - loadRust, - noop, - raw, - WASM_URL, -} from '../rust' -import { report } from '../report' - -const SINK = { n: 0 } -const bump = () => { - SINK.n++ -} - -interface Row { - name: string - hz: number - meanUs: number - rme: number -} -interface Section { - id: string - title: string - rows: Row[] -} -const sections: Section[] = [] - -async function run(id: string, title: string, bench: Bench): Promise { - await bench.warmup() - await bench.run() - report(title, bench) - sections.push({ - id, - title, - rows: bench.tasks.map(t => ({ - name: t.name, - hz: t.result?.hz ?? 0, - meanUs: (t.result?.mean ?? 0) * 1000, - rme: t.result?.rme ?? 0, - })), - }) -} - -const newBench = () => new Bench({ time: 500, warmupTime: 100 }) - -// --- TS twins of the Rust machines (benchmark/rust) -------------------------- -type CellCtx = { value: number; other: number } -const cellConfig = { - initial: 'idle' as const, - context: { value: 0, other: 0 }, - states: { - idle: { - on: { - hit: { - actions: [ - ({ - context, - setContext, - }: { - context: CellCtx - setContext: (p: Partial) => void - }) => setContext({ value: context.value + 1 }), - ], - }, - miss: { - actions: [ - ({ - context, - setContext, - }: { - context: CellCtx - setContext: (p: Partial) => void - }) => setContext({ other: context.other + 1 }), - ], - }, - }, - }, - }, -} -const tsCell = () => machine<'idle', CellCtx, { type: 'hit' | 'miss' }>(cellConfig) - -const pingPongConfig = { - initial: 'ping' as const, - context: {}, - states: { - ping: { entry: [bump], exit: [bump], on: { go: { target: 'pong' as const } } }, - pong: { entry: [bump], exit: [bump], on: { go: { target: 'ping' as const } } }, - }, -} -const tsPingPong = () => machine<'ping' | 'pong', object, { type: 'go' }>(pingPongConfig) - -function tsGuards(k: number) { - const candidates = Array.from({ length: k }, (_, i) => ({ - guard: ({ context }: { context: { pick: number } }) => context.pick === i, - actions: [bump], - })) - return machine<'idle', { pick: number }, { type: 'go' }>({ - initial: 'idle', - context: { pick: k - 1 }, - states: { idle: { on: { go: candidates } } }, - }) -} - -// Synthetic command sets with realistic labels for the subsequence filter. -const VERBS = ['Go to', 'Open', 'Create', 'Toggle', 'Invite', 'Archive', 'Rename', 'Share'] -const NOUNS = ['Dashboard', 'Issue', 'Pull Request', 'Settings', 'Project', 'Teammate', 'Board'] -function makeCommands(n: number): Command[] { - return Array.from({ length: n }, (_, i) => ({ - id: `cmd-${i}`, - label: `${VERBS[i % VERBS.length]} ${NOUNS[(i * 7) % NOUNS.length]} ${i}`, - group: NOUNS[i % NOUNS.length], - })) -} -const QUERIES = ['go', 'gpr', 'set', 'pro 1', 'to b', ''] - -// --- A. boundary floor --------------------------------------------------------- -function benchBoundary() { - const b = newBench() - const jsNoop = (x: number) => x - let x = 0 - b.add('js: function call', () => { - x = jsNoop(x + 1) & 0xffff - }) - b.add('wasm: noop(u32) call', () => { - x = noop(x + 1) & 0xffff - }) - return b -} - -// --- B. one event, observed field (the single-event throughput shape) ---------- -function benchCell(field: 'hit' | 'miss') { - const b = newBench() - const ts = tsCell() - ts.start() - ts.select.context('value').subscribe(bump) - const rs = createCell() - rs.start() - rs.select.context('value').subscribe(bump) - const handle = new raw.CellMachine() - handle.start() - const kind = field === 'hit' ? 0 : 1 - const event = { type: field } as const - b.add('ts machine', () => ts.send(event)) - b.add('rust machine', () => rs.send(event)) - b.add('rust raw', () => handle.send(kind, event)) - return b -} - -// --- C. state churn: exit + entry actions every event -------------------------- -function benchPingPong() { - const b = newBench() - const ts = tsPingPong() - ts.start() - const rs = createPingPong() - rs.start() - const handle = new raw.PingPongMachine() - handle.start() - const go = { type: 'go' } as const - b.add('ts machine', () => ts.send(go)) - b.add('rust machine', () => rs.send(go)) - b.add('rust raw', () => handle.send(0, go)) - return b -} - -// --- D. guard fallthrough, 8 candidates, the last wins -------------------------- -function benchGuards(k: number) { - const b = newBench() - const ts = tsGuards(k) - ts.start() - const rs = createGuards(k) - rs.start() - const go = { type: 'go' } as const - b.add('ts machine', () => ts.send(go)) - b.add('rust machine', () => rs.send(go)) - return b -} - -// --- E. palette: type a query, read results + activeId (computed) --------------- -function benchPalette(n: number) { - const b = newBench() - const commands = makeCommands(n) - const ts = machine(commandPaletteMachineConfig({ commands })) - ts.start() - ts.send({ type: 'open' }) - const rsIdx = createPalette(commands) - rsIdx.start() - rsIdx.send({ type: 'open' }) - const rsObj = createPaletteObjects(commands) - rsObj.start() - rsObj.send({ type: 'open' }) - let i = 0 - const typeAndRead = (m: typeof ts | typeof rsIdx) => { - m.send({ type: 'query.set', query: QUERIES[i++ % QUERIES.length]! }) - SINK.n += m.computed.results.length + (m.computed.activeId?.length ?? 0) - } - b.add('ts machine', () => typeAndRead(ts)) - b.add('rust machine (indices → own objects)', () => typeAndRead(rsIdx)) - b.add('rust machine (objects cross)', () => typeAndRead(rsObj)) - return b -} - -// --- F. construction: build + start one machine --------------------------------- -function benchConstruct() { - const b = newBench() - b.add('ts machine: machine(config) + start', () => { - const m = tsCell() - m.start() - SINK.n += m.context.value - }) - b.add('rust machine: createCell() + start', () => { - const m = createCell() - m.start() - SINK.n += m.context.value - }) - b.add('rust raw: new CellMachine() + start', () => { - const h = new raw.CellMachine() - SINK.n += h.start() === 0 ? 1 : 0 - }) - return b -} - -function measureInit(): { compileMs: number; firstInitMs: number } { - const bytes = readFileSync(WASM_URL) - const runs = 20 - const t0 = performance.now() - for (let i = 0; i < runs; i++) new WebAssembly.Module(bytes) - const compileMs = (performance.now() - t0) / runs - const t1 = performance.now() - loadRust() - const firstInitMs = performance.now() - t1 - return { compileMs, firstInitMs } -} - -export async function runWasm() { - console.log('\n========== TS machines vs Rust machines (both on the Rust engine) ==========') - const init = measureInit() - console.log( - `wasm init: compile ${init.compileMs.toFixed(2)} ms (avg of 20) · first initSync ${init.firstInitMs.toFixed(2)} ms`, - ) - await run('boundary', 'A. boundary floor', benchBoundary()) - await run('cell-hit', 'B. one event, observed field (hit)', benchCell('hit')) - await run('cell-miss', 'B2. one event, unobserved field (miss)', benchCell('miss')) - await run('pingpong', 'C. state churn (exit + entry actions)', benchPingPong()) - await run('guards-8', 'D. guard fallthrough (8 candidates)', benchGuards(8)) - for (const n of [11, 1_000, 10_000]) { - await run( - `palette-${n}`, - `E. palette: type + read computed (${n.toLocaleString()} commands)`, - benchPalette(n), - ) - } - await run('construct', 'F. construct + start one machine', benchConstruct()) - console.log('(anti-DCE SINK:', SINK.n, ')') - - const out = process.env.SPIKE_OUT - if (out) { - writeFileSync( - out, - JSON.stringify( - { node: process.version, date: new Date().toISOString(), init, sections }, - null, - 2, - ), - ) - console.log(`spike results written to ${out}`) - } -} diff --git a/benchmark/wasm-built.ts b/benchmark/wasm-built.ts deleted file mode 100644 index d2c75d5..0000000 --- a/benchmark/wasm-built.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { existsSync } from 'node:fs' - -/** - * Whether the benchmark machines were built to wasm (`pnpm build:wasm`). The Rust rows - * are skipped when they were not. - */ -export function wasmBuilt(): boolean { - return existsSync(new URL('./rust/pkg/dunky_benchmark_bg.wasm', import.meta.url)) -} - -export const WASM_SKIPPED = - '⚠️ wasm not built — skipping the Rust-machine rows (run `pnpm build:wasm`).' diff --git a/crates/core-wasm/Cargo.toml b/crates/core-wasm/Cargo.toml deleted file mode 100644 index ac0780b..0000000 --- a/crates/core-wasm/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "dunky-core-wasm" -description = "The wasm that @dunky.dev/state-machine ships: the Rust engine plus the runtime for TS-authored machines." -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -dunky-wasm = { workspace = true, features = ["runtime"] } -wasm-bindgen = { workspace = true } diff --git a/crates/core-wasm/src/lib.rs b/crates/core-wasm/src/lib.rs deleted file mode 100644 index c1f84c1..0000000 --- a/crates/core-wasm/src/lib.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! The wasm module of `@dunky.dev/state-machine`: the runtime for TS-authored machines -//! (see `dunky_wasm::runtime`). Built by `pnpm build:wasm` into `packages/core/wasm`. - -use std::alloc::{GlobalAlloc, Layout, System}; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use wasm_bindgen::prelude::*; - -pub use dunky_wasm::runtime::{JsConfig, JsMachine}; - -// The engine's memory lives outside the JS heap, so a JS heap sample misses it. The -// allocator counts the live bytes for the benchmark's memory section. -struct Counted; - -static LIVE: AtomicUsize = AtomicUsize::new(0); - -unsafe impl GlobalAlloc for Counted { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - let ptr = unsafe { System.alloc(layout) }; - if !ptr.is_null() { - LIVE.fetch_add(layout.size(), Ordering::Relaxed); - } - ptr - } - - unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - unsafe { System.dealloc(ptr, layout) }; - LIVE.fetch_sub(layout.size(), Ordering::Relaxed); - } - - unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { - let moved = unsafe { System.realloc(ptr, layout, size) }; - if !moved.is_null() { - LIVE.fetch_add(size, Ordering::Relaxed); - LIVE.fetch_sub(layout.size(), Ordering::Relaxed); - } - moved - } -} - -#[global_allocator] -static ALLOCATOR: Counted = Counted; - -/// The engine's live heap bytes. -#[wasm_bindgen(js_name = engineHeapBytes)] -pub fn engine_heap_bytes() -> f64 { - LIVE.load(Ordering::Relaxed) as f64 -} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f85edd2..7292ccf 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -13,9 +13,6 @@ default = ["derive", "serde"] derive = ["dep:dunky-macros"] # Per-field serialization (SerializeFields) for bindings that mirror context into JS. serde = ["dep:serde"] -# The binding API: the `Host` trait and the `External` parts it runs, so a runtime can -# drive machines written in another language (TS machines on the wasm / JSI runtimes). -host = [] [dependencies] dunky-macros = { workspace = true, optional = true } diff --git a/crates/core/README.md b/crates/core/README.md index 9ed943b..1adba7c 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -1,9 +1,9 @@ # dunky-core -The engine of [`@dunky.dev/state-machine`](../../packages/core), and the crate to write -machines in Rust. [`packages/core/SPEC.md`](../../packages/core/SPEC.md) is its behavior -contract; the Rust tests in `tests/` are ported from `packages/core/tests`. Machines -written in TypeScript run on it too (through `dunky-wasm`'s runtime). +Dunky's state machines in Rust: the twin of the TypeScript engine, +[`@dunky.dev/state-machine`](../../packages/core). Both follow one behavior contract, +[`packages/core/SPEC.md`](../../packages/core/SPEC.md); the Rust tests in `tests/` are +ported from `packages/core/tests`. Use it in Rust programs and native bindings. ```rust use dunky_core::{Config, Context, Event, Machine, State, Types}; @@ -42,7 +42,5 @@ assert_eq!(dialog.state(), DialogState::Open); ## Learn more - [`SPEC.md`](./SPEC.md) — what the crate guarantees: authoring in Rust, timers as - commands, change reporting, and the binding API (cargo feature `host`). The behavior - contract every machine follows is [`packages/core/SPEC.md`](../../packages/core/SPEC.md). -- [`dunky-wasm`](../wasm) — export a machine to JavaScript; wrap it with `fromWasm` from - `@dunky.dev/state-machine`. + commands, change reporting, and how it stays in sync with the TS engine. The behavior + contract both engines follow is [`packages/core/SPEC.md`](../../packages/core/SPEC.md). diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md index 604bfd9..cf753a6 100644 --- a/crates/core/SPEC.md +++ b/crates/core/SPEC.md @@ -2,25 +2,25 @@ ## Overview -`dunky-core` is the engine of [`@dunky.dev/state-machine`](../../packages/core), in Rust. -Every machine runs on it: a machine written in Rust with this crate, and a machine -written in TypeScript, through the runtime in [`dunky-wasm`](../wasm/SPEC.md). +`dunky-core` is the Rust implementation of Dunky's state machines. The TypeScript +implementation is [`@dunky.dev/state-machine`](../../packages/core). They are two +engines with one behavior contract, +[`packages/core/SPEC.md`](../../packages/core/SPEC.md): flat states, run-to-completion, +guards, actions, effects, timed transitions, derived data, data-reactions, observation, +the connector, composition, and the cross-instance store. -The behavior contract is [`packages/core/SPEC.md`](../../packages/core/SPEC.md): flat -states, run-to-completion, guards, actions, effects, timed transitions, derived data, -data-reactions, observation, the connector, composition, and the cross-instance store. -This SPEC covers only what is specific to the crate: authoring a machine in Rust, timers -without a clock, and the binding API. +This SPEC covers only what is specific to the crate: authoring a machine in Rust, and +timers without a clock. ``` - Rust types + config builder a binding (dunky-wasm, React Native) - | | host: runs the external parts, - | build once | hears every change - v | + Rust types + config builder + | + | build once + v config (shared by every machine of the type) - | | - v v - machine ------ timer commands out ------> host clock + | + v + machine ------ timer commands out ------> host clock (an event loop, a test clock) ^ | | | +-------- changes out (state, fields) | | | @@ -29,25 +29,33 @@ without a clock, and the binding API. ## Intent -Write the engine once and run it for every host: JS through wasm, React Native through -JSI, and Rust programs directly. The engine stays free of IO and platform, so one build -behaves the same everywhere; a host supplies the clock and the boundary. +Bring the same machines to Rust programs and native bindings (React Native over JSI), +optimized for Rust with no trade-off made for another runtime. The engine stays free of +IO and platform, so one build behaves the same everywhere; a host supplies the clock. ## Scope & boundaries **In scope.** The engine runtime; typed authoring of machines; timers as commands; change -reporting; and the binding API (cargo feature `host`). +reporting. **Out of scope — invariants, not preferences.** - **No IO, no clock, no platform.** The engine never reads time and never schedules work. A host does. -- **No JS.** Bindings live in their own crates: `dunky-wasm` for JS, `crates/uniffi` for - React Native. +- **No JS.** Bindings live in their own crates (`crates/uniffi` for React Native). - **One thread.** A machine and what it shares stay on one thread. A binding that crosses threads enforces that itself. - **At most 64 context fields** per machine: each field is one bit of the change mask. +## Staying in sync with the TS engine + +- A behavior change lands in `packages/core/SPEC.md`, in both engines, and in both test + suites, in the same PR. +- The Rust tests in `tests/` are ported from `packages/core/tests`, file by file: a new + behavior test on one side gets its twin on the other. +- What only one engine has (this SPEC, or a TS-only API like `setup()` typing) stays out + of the shared contract. + ## Authoring a machine in Rust - A machine type names three types: its **states** (an enum without data), its @@ -64,12 +72,6 @@ reporting; and the binding API (cargo feature `host`). equality (`PartialEq` by default). - With the `serde` feature, a context field serializes by index and an event rebuilds from its kind and its payload: what a binding needs to cross into another runtime. -- **TS types.** A machine type knows its TypeScript types (`TsType`), so a binding can - generate them instead of anyone writing them twice. The derives write them for the - states, for the events (with `#[event(deserialize)]`) and for the context (with - `#[context(serialize)]`); the data types they hold derive `TsType`. Names and shapes - follow the serde attributes the values cross with: `None` is `null`, a field serde - skips when it is `None` is optional, every number is a `number`. ## The behavior contract @@ -91,12 +93,12 @@ The contract is `packages/core/SPEC.md`'s. These guarantees are specific to the ### Re-entrancy -- User code written in Rust (guards, actions, effects, delays, derived definitions) gets - params — the context, the event, derived values, a way to write context, a way to - dispatch — not the machine. -- Observers (subscriptions, selections, lifecycle listeners, effect cleanups) and host - callbacks run while the engine holds no mutable borrow: they may read, dispatch, or - write context. A dispatch made during a run-to-completion step is queued. +- User code (guards, actions, effects, delays, derived definitions) gets params — the + context, the event, derived values, a way to write context, a way to dispatch — not + the machine. +- Observers (subscriptions, selections, lifecycle listeners, effect cleanups) run while + the engine holds no borrow: they may read, dispatch, or write context. A dispatch made + during a run-to-completion step is queued. ### Equality @@ -107,70 +109,24 @@ The contract is `packages/core/SPEC.md`'s. These guarantees are specific to the - A missing named implementation panics in debug builds, and warns and does nothing in release builds. -- A runaway step (more than 10,000 items in one run-to-completion step) stops. Without a - host it panics in debug builds and drops the queue in release builds. With a host, the - step stops and the binding raises the failure in its own runtime. +- A runaway step (more than 10,000 items in one run-to-completion step) panics in debug + builds and drops the queue in release builds. - A panicking cleanup does not leak the others: every cleanup runs, then the first panic resumes. -## The binding API - -A **host** (cargo feature `host`) connects a machine to another runtime. It has two jobs: - -- **Run the external parts.** A config may reference guards, actions, effects, delays - and derived definitions by number instead of Rust code — for example, the user code of - a machine written in TypeScript. The engine calls the host for them while it holds no - mutable borrow, so the host may read derived values, write context, and dispatch. -- **Hear every change.** After each effective change the engine notifies the host: once - per change, in order, with no borrow held, with the current state and what changed - since the previous notify. - -Fewer calls, same order: - -- A run of external actions is one host call, and so is a guard walk over a handler's - candidates (the host returns the first that passes). -- The host keeps the cleanups of the effects it starts. When the state's effects stop, - the engine tells it once, and the host runs them all, in start order, even past one - that fails. -- A host may hold a notification or an effects stop back and deliver it first with its - next callback. The engine settles the host — everything held back is delivered — before - it switches state, when a transition ends, and when the machine stops. -- A transition with a host id that leaves its state runs **whole** on the host, in one - call: the effects stop, exit actions, the transition's actions, the switch and its - notification, entry actions, the target's effects. The engine switches state after the - call, so a binding gives a host id only when nothing the host runs can read the - engine's state mid-transition (no derived values) and the target has no host delay. If - the call fails before the switch, the machine stays where it was. - -The contract: - -- **Failure.** When a host callback fails, the host raises the machine's halt flag. The - engine stops the current step at its next checkpoint: no more user code runs in that - step, nothing more applies, and what is queued stays queued. The binding raises the - failure and lowers the flag. A derived value evaluated during a failure is not cached. -- **Context the host owns.** A host that keeps the context itself (the JS object of a TS - machine) reports which fields a write changed, and announces the write to its own - observers. The engine stamps the fields at once, or at its next safe point when it is - in the middle of a step, then runs the watchers; it does not notify for that write. - Without watchers, a report only stamps: it runs no code and cannot fail. -- **External derived values.** The host keeps their values. An evaluation tells the - engine whether the value changed, by the host's own equality, and what it read — - context fields, the state, other derived values — or nothing when it read the same - inputs as the previous evaluation. The engine tracks staleness from what it read. -- A machine written in Rust has no external parts: its host only hears changes. - ## Performance guarantees - Notifying observers in the steady state allocates nothing. - A derived value is fresh by a stamp comparison: a read re-reads and compares no input value. - One config per machine type, shared by every instance. +- A machine pays only for what it uses: field stamps grow with the fields it writes, + broadcasts exist from the first subscriber, and states without effects or timers do no + effect work. ## Edge cases that carry design meaning -- A host callback may dispatch; the event runs after the current item. -- A write a host makes in the middle of a step applies at the next safe point, before the - step ends, so watchers and notifications see it in order. +- An observer may dispatch; the event runs after the current item. - A due timer reported after stop, after the state was left, or after the state was entered again is ignored. - A context with more than 64 fields is rejected when the machine is built. diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs index 106f9aa..08304e9 100644 --- a/crates/core/src/computed.rs +++ b/crates/core/src/computed.rs @@ -18,9 +18,6 @@ pub(crate) type ComputedEval = Rc Fn(&ComputedParams<'a, T>) -> R pub(crate) enum ComputedSource { Fn(ComputedEval), - /// Evaluated by the machine's host, by the binding's own id. - #[cfg(feature = "host")] - External(u32), } pub(crate) struct ComputedDef { @@ -60,27 +57,6 @@ impl std::fmt::Debug for ComputedKey { } } -/// What a host's evaluation of an external computed value reports ([`Host::computed`]). -/// -/// [`Host::computed`]: crate::Host::computed -#[cfg_attr(not(feature = "host"), allow(dead_code))] -pub struct Evaluation { - /// The value changed, by the host's own equality. - pub changed: bool, - /// What the evaluation read, or `None` when it read the same inputs as the previous one. - pub reads: Option, -} - -/// The inputs an evaluation read. -#[cfg_attr(not(feature = "host"), allow(dead_code))] -pub struct Reads { - /// Context fields, one bit each. - pub fields: u64, - pub state: bool, - /// Other computed values, by id. - pub computed: Vec, -} - /// What a computed definition reads. Reads are tracked: `context.()`, `state()` and /// `computed(key)` each become an input of this value. pub struct ComputedParams<'a, T: Types> { @@ -202,13 +178,7 @@ fn fresh(core: &Core, inner: &Inner, id: usize) -> Option(core: &Core, inner: &Inner, id: usize) -> Rc { let def = &inner.config.0.computed[id]; - // One arm without the `host` feature. - #[allow(clippy::infallible_destructuring_match)] - let eval = match &def.eval { - ComputedSource::Fn(f) => f, - #[cfg(feature = "host")] - ComputedSource::External(ext) => return recompute_external(core, inner, id, *ext), - }; + let ComputedSource::Fn(eval) = &def.eval; let slot_cell = &core.computed.slots[id]; let buffer = { let mut slot = slot_cell.borrow_mut(); @@ -245,16 +215,6 @@ fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc(core: &Core, inner: &Inner, id: usize) -> Rc( - core: &Core, - inner: &Inner, - id: usize, - ext: u32, -) -> Rc { - let def = &inner.config.0.computed[id]; - let Some(host) = inner.host() else { - panic!("[machine] computed \"{}\" needs a host", def.name) - }; - let slot_cell = &core.computed.slots[id]; - { - let mut slot = slot_cell.borrow_mut(); - if slot.evaluating { - panic!("[machine] computed \"{}\" depends on itself", def.name); - } - slot.evaluating = true; - } - let evaluation = host.computed(ext); - let mut slot = slot_cell.borrow_mut(); - slot.evaluating = false; - // Kept even after a failure: the host compares its next reads with these. - if let Some(reads) = evaluation.reads { - slot.ctx_deps = reads.fields; - slot.reads_state = reads.state; - slot.computed_deps = reads.computed; - } - if inner.aborted() { - slot.value = None; - return Rc::new(()); - } - let changed = evaluation.changed || slot.value.is_none(); - let marker = slot.value.get_or_insert_with(|| Rc::new(())).clone(); - if changed { - slot.changed_at = core.tick; - } - slot.validated_at = core.tick; - marker -} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 38cb916..ab24a25 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -37,10 +37,6 @@ pub enum Guard { /// True iff any guard passes. Empty → false. Or(Vec>), Not(Box>), - /// Evaluated by the machine's [`Host`](crate::Host): a binding's callback (e.g. a JS - /// guard of a TS-authored machine), by the binding's own id. - #[cfg(feature = "host")] - External(u32), } impl Guard { @@ -73,13 +69,6 @@ pub enum Action { Fn(ActionFn), Named(Named>), OneOf(Vec>), - /// Run by the machine's [`Host`](crate::Host), with no internal borrow held. - #[cfg(feature = "host")] - External(u32), - /// A run of host actions, run by the [`Host`](crate::Host) in one call (in order, - /// stopping at the first failure), by the binding's own list id. - #[cfg(feature = "host")] - ExternalList(u32), } impl Action { @@ -135,9 +124,6 @@ impl Branch { pub enum Effect { Fn(EffectFn), Named(Named>), - /// Started by the machine's [`Host`](crate::Host), with no internal borrow held. - #[cfg(feature = "host")] - External(u32), } impl Effect { @@ -159,9 +145,6 @@ impl From<&'static str> for Effect { pub enum Delay { Ms(u32), Named(Named>), - /// Resolved by the machine's [`Host`](crate::Host). - #[cfg(feature = "host")] - External(u32), } impl From for Delay { @@ -181,10 +164,6 @@ pub struct Transition { pub(crate) target: Option, pub(crate) guard: Option>, pub(crate) actions: Vec>, - /// The binding's id for this transition: when it leaves the state, the host runs it - /// whole (`Host::transition`). - #[cfg(feature = "host")] - pub(crate) host_id: Option, } /// Builds one transition candidate inside `StateBuilder::on` / `after`. @@ -233,32 +212,17 @@ impl TransitionBuilder { self.0.actions.push(action); self } - /// The binding's id for this transition. When it leaves the state, the host runs it - /// whole, in one call: effect cleanups, exit actions, the transition's actions, the - /// switch and its notification, entry actions and effects. Only for a host whose code - /// cannot read the engine's state mid-transition (e.g. a machine without derived - /// values), and a target without host delays: the engine switches after the call. - #[cfg(feature = "host")] - pub fn host_id(mut self, id: u32) -> Self { - self.0.host_id = Some(id); - self - } } /// A handler for one event (or one `after` delay): candidates in order, first passing guard wins. pub(crate) struct Entry { candidates: Vec>, - /// The host evaluates the candidates' guards in one call, by the binding's own list id. - #[cfg(feature = "host")] - pub(crate) pick: Option, } impl Default for Entry { fn default() -> Self { Self { candidates: Vec::new(), - #[cfg(feature = "host")] - pick: None, } } } @@ -372,23 +336,6 @@ impl StateBuilder { self.0.tags.push(tag); self } - /// The host evaluates the guards of the handler for `kind` in one call: its list `list`. - #[cfg(feature = "host")] - pub fn pick(mut self, kind: ::Kind, list: u32) -> Self { - self.0.on[::kind_index(kind)].pick = Some(list); - self - } - /// The host evaluates the guards of the most recent `after` in one call. - #[cfg(feature = "host")] - pub fn after_pick(mut self, list: u32) -> Self { - self.0 - .after - .last_mut() - .expect("after_pick() needs a preceding after()") - .1 - .pick = Some(list); - self - } } fn empty_transition() -> Transition { @@ -396,8 +343,6 @@ fn empty_transition() -> Transition { target: None, guard: None, actions: Vec::new(), - #[cfg(feature = "host")] - host_id: None, } } @@ -441,18 +386,6 @@ impl Config { ) } - /// A builder for a machine whose states and event types are known only at runtime - /// (a binding building a TS-authored machine): `states` and `kinds` are their counts. - #[cfg(feature = "host")] - pub fn builder_sized( - initial: T::State, - context: T::Context, - states: usize, - kinds: usize, - ) -> ConfigBuilder { - Self::builder_with_counts(initial, context, states, kinds) - } - fn builder_with_counts( initial: T::State, context: T::Context, @@ -539,13 +472,6 @@ impl ConfigBuilder { self } - /// The host evaluates the guards of the any-state handler for `kind` in one call. - #[cfg(feature = "host")] - pub fn pick_any(&mut self, kind: ::Kind, list: u32) -> &mut Self { - self.inner.on_any[::kind_index(kind)].pick = Some(list); - self - } - pub fn guard( &mut self, name: &'static str, @@ -600,34 +526,6 @@ impl ConfigBuilder { ComputedKey::new(id) } - /// A computed value the machine's [`Host`](crate::Host) evaluates and keeps (e.g. a JS - /// function of a TS-authored machine). Returns its id. - #[cfg(feature = "host")] - pub fn computed_external(&mut self, name: &'static str) -> usize { - let id = self.inner.computed.len(); - self.inner.computed.push(ComputedDef { - name, - eval: ComputedSource::External(id as u32), - // The host decides whether a new value changed. - eq: |_, _| false, - }); - id - } - - /// Data-reaction on computed value `id` (for computed values defined by id). - #[cfg(feature = "host")] - pub fn watch_computed_id( - &mut self, - id: usize, - actions: impl IntoIterator>, - ) -> &mut Self { - self.inner.watch.push(Watch { - source: WatchSource::Computed(id), - actions: actions.into_iter().collect(), - }); - self - } - /// Data-reaction on a context field: its actions run (deferred) whenever the field /// changes, in any state, while the machine runs. pub fn watch( @@ -722,8 +620,6 @@ impl Registry<'_, T> { fn guard(&self, guard: &mut Guard) { match guard { Guard::Fn(_) => {} - #[cfg(feature = "host")] - Guard::External(_) => {} Guard::Named(n) => n.imp = self.guards.get(n.name).cloned(), Guard::And(gs) | Guard::Or(gs) => gs.iter_mut().for_each(|g| self.guard(g)), Guard::Not(g) => self.guard(g), @@ -733,8 +629,6 @@ impl Registry<'_, T> { for a in actions { match a { Action::Fn(_) => {} - #[cfg(feature = "host")] - Action::External(_) | Action::ExternalList(_) => {} Action::Named(n) => n.imp = self.actions.get(n.name).cloned(), Action::OneOf(branches) => { for b in branches { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7d1833b..6e5a4b9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,10 +1,10 @@ -//! `dunky-core` — the engine of `@dunky.dev/state-machine`, and the crate to write -//! machines in Rust. +//! `dunky-core` — the Rust implementation of Dunky's state machines, next to the TS one +//! (`@dunky.dev/state-machine`). //! -//! The behavior contract is `packages/core/SPEC.md`; what is specific to this crate is -//! `crates/core/SPEC.md`. The engine (flat states, run-to-completion, guards, actions, -//! effects, `after` timers, computed data, watchers, selections, connector, composition, -//! store) serves Rust programs, and machines written in TypeScript through `dunky-wasm`. +//! Both implement the same behavior contract, `packages/core/SPEC.md`; what is specific +//! to this crate is `crates/core/SPEC.md`. The engine (flat states, run-to-completion, +//! guards, actions, effects, `after` timers, computed data, watchers, selections, +//! connector, composition, store) serves Rust programs and native bindings. //! //! Rust-specific shape: //! - **Typed**: states, events and context are Rust types (`#[derive(State, Event, Context)]`). @@ -28,23 +28,18 @@ mod selection; mod store; mod timers; mod traits; -mod ts; pub mod testing; pub use broadcast::Subscription; pub use compose::{Combined, Composition, Member}; pub use computed::{ComputedKey, ComputedParams}; -#[cfg(feature = "host")] -pub use computed::{Evaluation, Reads}; pub use config::{ Action, ActionFn, Branch, Cleanup, Config, ConfigBuilder, Delay, Effect, Guard, StateBuilder, TransitionBuilder, }; pub use connector::{ConnectSnapshot, Connector, Reaction}; pub use machine::{Changes, Machine, Sender}; -#[cfg(feature = "host")] -pub use machine::{Host, HostTransition}; pub use params::{ActionParams, GuardParams, View}; pub use selection::Selection; pub use store::Store; @@ -52,10 +47,9 @@ pub use timers::{Command, TimerId}; pub use traits::{Context, EventEnum, Field, StateEnum, Types}; #[cfg(feature = "serde")] pub use traits::{DeserializeEvent, SerializeFields}; -pub use ts::TsType; #[cfg(feature = "derive")] -pub use dunky_macros::{Context, Event, State, TsType}; +pub use dunky_macros::{Context, Event, State}; /// The `machine.init` marker name: what a boot effect or a data-reaction sees as its /// event type (`ActionParams::event()` returns `None` for it). @@ -65,6 +59,4 @@ pub const MACHINE_INIT: &str = "machine.init"; pub mod __private { #[cfg(feature = "serde")] pub use serde; - - pub use crate::ts::{key as ts_key, literal as ts_literal, object as ts_object}; } diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs index 363858f..d1a5823 100644 --- a/crates/core/src/machine.rs +++ b/crates/core/src/machine.rs @@ -3,9 +3,6 @@ //! Borrow discipline (the Rust shape of the TS engine's re-entrancy rules): //! - Rust actions, guards, effects, delays and computed definitions run while the core is //! borrowed; they get params instead of the machine handle. -//! - Host callbacks (`External` guards/actions/effects/delays/computed — e.g. the JS -//! functions of a TS-authored machine) run with no MUTABLE borrow held: they may read -//! the machine, mark context changes ([`Machine::mark_changed`]) and send. //! - Observers (subscribe, selections, lifecycle listeners, effect cleanups) run with NO //! borrow held, so they may read the machine, send to it, or patch it. Sends made while //! a flush is in progress are queued and run after the current item, never interleaved. @@ -16,7 +13,7 @@ use std::collections::VecDeque; use std::rc::{Rc, Weak}; use crate::broadcast::{Broadcast, Subscription}; -use crate::computed::{self, ComputedKey, ComputedRuntime, Evaluation}; +use crate::computed::{self, ComputedKey, ComputedRuntime}; use crate::config::{ Action, Cleanup, Config, Delay, Effect, Entry, Guard, Transition, WatchSource, missing, }; @@ -29,69 +26,6 @@ use crate::traits::{Context, EventEnum, StateEnum, Types}; /// feedback loop (a watcher writing what it watches, actions sending in a cycle). const MAX_FLUSH: u32 = 10_000; -/// The binding that runs a machine's `External` parts — e.g. the JS functions of a -/// TS-authored machine — and hears about its changes. On a failure (a JS exception) the -/// host keeps the error, returns a placeholder, and raises the machine's halt flag -/// ([`Machine::halt_flag`]); the engine then stops the current step and the binding -/// rethrows. -/// -/// Part of the binding API (cargo feature `host`). -#[cfg_attr(not(feature = "host"), allow(dead_code))] -pub trait Host { - fn guard(&self, id: u32, event: Option<&T::Event>) -> bool; - fn action(&self, id: u32, event: Option<&T::Event>); - /// Start effect `id`. The host keeps its cleanup, if any, for [`Host::stop_effects`]. - fn effect(&self, id: u32, event: Option<&T::Event>); - /// The effects the host started stop: run their cleanups, in start order, all of them - /// even if one fails. A host may hold this back to deliver it with its next callback, - /// first, and no later than [`Host::settle`] — which the engine calls before it - /// switches state. - fn stop_effects(&self); - fn delay(&self, id: u32, event: Option<&T::Event>) -> u32; - /// Evaluate external computed value `id`. The host keeps the value; the engine keeps - /// what the evaluation read, to tell when the value goes stale. - fn computed(&self, id: u32) -> Evaluation; - /// Evaluate the guards of candidate list `list` in order: the index of the first that - /// passes (a candidate without a guard passes). - fn pick(&self, list: u32, event: Option<&T::Event>) -> Option; - /// Run the actions of list `list` in order, stopping at the first failure. - fn actions(&self, list: u32, event: Option<&T::Event>); - /// The machine changed (once per effective change, like a coarse subscriber), with - /// no borrow held. `state` is the current state; `changes` is what changed since the - /// previous notify. A host may hold a notification back to deliver it with its next - /// callback, as long as it delivers it first and no later than [`Host::settle`]. - fn notify(&self, state: T::State, changes: Changes); - /// A transition finished: deliver any notification held back. - fn settle(&self); - /// Run transition `id` whole (a transition built with a host id that leaves its - /// state): if `stop`, stop the effects; run `from`'s exit actions and the - /// transition's actions; announce the switch to `to`; run `to`'s entry actions; and if - /// `start`, start `to`'s effects (keeping their cleanups). - fn transition( - &self, - id: u32, - from: T::State, - to: T::State, - event: Option<&T::Event>, - stop: bool, - start: bool, - ) -> HostTransition; -} - -/// How a transition the host ran whole ended ([`Host::transition`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(not(feature = "host"), allow(dead_code))] -pub enum HostTransition { - /// Every step ran. - Done, - /// Failed before the switch: the machine stays where it was. - FailedBefore, - /// Failed in the switch's notification or an entry action: no effect started. - FailedAfter, - /// Failed while starting the effects: some may have started. - FailedInEffects, -} - pub(crate) enum Item { Event(T::Event), After { @@ -120,8 +54,6 @@ pub(crate) struct Core { /// Bumped on every state entry; a timer scheduled under another generation is stale. entry_counter: u64, state_cleanups: Vec, - /// The host started effects in the current state (it keeps their cleanups). - host_effects: bool, timers: Vec>, next_timer_id: u32, commands: Vec, @@ -134,8 +66,6 @@ pub(crate) struct Core { pub(crate) state_changed_at: u64, /// Effective changes made inside the current borrow; drained into notifications. pending_notifies: u32, - /// What those changes touched, for the host's next notify. - notify_changes: Changes, /// Changes since the last `take_changes()`, for bindings. changed_fields: u64, changed_state: bool, @@ -156,7 +86,6 @@ impl Core { } self.stamp_fields(mask); self.changed_fields |= mask; - self.notify_changes.fields |= mask; self.pending_notifies += 1; } @@ -176,17 +105,11 @@ impl Core { } fn set_state(&mut self, next: T::State) { - self.switch_state(next); - self.notify_changes.state = true; - self.pending_notifies += 1; - } - - /// Switch to `next` without owing a notification (the host announced it). - fn switch_state(&mut self, next: T::State) { self.state = next; self.tick += 1; self.state_changed_at = self.tick; self.changed_state = true; + self.pending_notifies += 1; } } @@ -201,15 +124,6 @@ pub(crate) struct Inner { watch_stamps: RefCell>, lifecycle: OnceCell>, stop_listeners: OnceCell>, - host: OnceCell>>, - /// Context changes a host marked while the core was borrowed; applied at the next - /// safe point. - external_mask: Cell, - /// The engine's own failure (a feedback loop) when a host is attached. - failure: RefCell>, - /// The current step must stop: a host callback failed (the host raises it) or the - /// engine did. Shared with the host, so a check is a load. - halt: Rc>, pub(crate) weak_self: Weak>, } @@ -272,7 +186,6 @@ impl Machine { running: false, entry_counter: 0, state_cleanups: Vec::new(), - host_effects: false, timers: Vec::new(), next_timer_id: 1, commands: Vec::new(), @@ -281,7 +194,6 @@ impl Machine { field_changed_at: Vec::new(), state_changed_at: 0, pending_notifies: 0, - notify_changes: Changes::default(), changed_fields: 0, changed_state: false, }; @@ -294,10 +206,6 @@ impl Machine { watch_stamps: RefCell::new(vec![0; config.0.watch.len()]), lifecycle: OnceCell::new(), stop_listeners: OnceCell::new(), - host: OnceCell::new(), - external_mask: Cell::new(0), - failure: RefCell::new(None), - halt: Rc::new(Cell::new(false)), weak_self: weak.clone(), })) } @@ -309,13 +217,6 @@ impl Machine { m } - /// Attach the host that runs this machine's `External` parts. A machine has one host - /// for life: returns false when it already has one. - #[cfg(feature = "host")] - pub fn set_host(&self, host: Rc>) -> bool { - self.0.host.set(host).is_ok() - } - pub fn config(&self) -> &Config { &self.0.config } @@ -387,51 +288,6 @@ impl Machine { }); } - /// A host changed fields of a context it owns (a TS machine's JS context): stamp them - /// and run the watchers — right away when possible, else at the next safe point. The - /// host notifies its own observers of the write. Without watchers it only stamps: no - /// code runs. - #[cfg(feature = "host")] - pub fn mark_changed(&self, mask: u64) { - if mask == 0 { - return; - } - let inner = &*self.0; - let applied = match inner.core.try_borrow_mut() { - Ok(mut core) => { - core.stamp_fields(mask); - true - } - Err(_) => false, - }; - if !applied { - inner.external_mask.set(inner.external_mask.get() | mask); - return; - } - if inner.config.0.watch.is_empty() { - return; - } - inner.with_flush(|inner| { - if let Ok(core) = inner.core.try_borrow() { - inner.detect_watched(&core, mask); - } - inner.drain_notifies(); - }); - } - - /// The engine's own failure since the last call (a feedback loop), when a host is attached. - #[cfg(feature = "host")] - pub fn take_failure(&self) -> Option { - self.0.failure.borrow_mut().take() - } - - /// The flag that stops the current step. A host raises it when a callback fails and - /// lowers it once the binding has rethrown, before the next call. - #[cfg(feature = "host")] - pub fn halt_flag(&self) -> Rc> { - self.0.halt.clone() - } - /// Coarse subscription: fires on any later change (state or context), never on subscribe. pub fn subscribe(&self, listener: impl Fn() + 'static) -> Subscription { self.0.bus().add(Rc::new(listener)) @@ -469,7 +325,7 @@ impl Machine { inner.start_effects(state, None); true }); - if started && !inner.aborted() { + if started { if let Some(listeners) = inner.lifecycle.get() { inner.with_flush(|_| listeners.notify()); } @@ -490,12 +346,9 @@ impl Machine { // Watchers need no teardown: they detect only while running, and a run // still queued is dropped when it comes up. inner.stop_effects(); - if let Some(host) = inner.host() { - host.settle(); - } true }); - if stopped && !inner.aborted() { + if stopped { if let Some(listeners) = inner.stop_listeners.get() { inner.with_flush(|_| listeners.notify()); } @@ -524,7 +377,7 @@ impl Machine { .add(Rc::new(listener)) } - /// Host callback: the timer `id` (from a [`Command::StartTimer`]) came due. + /// The host clock: timer `id` (from a [`Command::StartTimer`]) came due. pub fn fire_timer(&self, id: TimerId) { let item = { let mut core = self.0.core.borrow_mut(); @@ -581,15 +434,6 @@ impl Inner { self.bus.get_or_init(Broadcast::new) } - pub(crate) fn host(&self) -> Option<&Rc>> { - self.host.get() - } - - /// The current step must stop: a host callback failed, or the engine itself did. - pub(crate) fn aborted(&self) -> bool { - self.halt.get() - } - /// Run `f` as part of a flush: re-entrant sends queue instead of nesting, and the queue /// drains after `f` unless an outer flush owns it. fn with_flush(&self, f: impl FnOnce(&Self) -> R) -> R { @@ -611,9 +455,6 @@ impl Inner { let _reset = FlushReset(&self.flushing, false); let mut ticks = 0u32; loop { - if self.aborted() { - break; // the binding rethrows; what is still queued stays queued - } let item = self.queue.borrow_mut().pop_front(); let Some(item) = item else { break }; ticks += 1; @@ -623,11 +464,6 @@ impl Inner { "[machine] one flush exceeded {MAX_FLUSH} steps — feedback loop \ (e.g. a watcher writing the field it watches, or actions sending in a cycle)" ); - if self.host.get().is_some() { - *self.failure.borrow_mut() = Some(msg); - self.halt.set(true); - break; - } if cfg!(debug_assertions) { panic!("{msg}"); } @@ -671,7 +507,6 @@ impl Inner { } } } - self.absorb_external(); } /// The first candidate whose guard passes. @@ -686,157 +521,46 @@ impl Inner { { return Some(first); } - // The host evaluates the whole list in one call, with no borrow held. - #[cfg(feature = "host")] - if let Some(list) = entry.pick - && let Some(host) = self.host() - { - let picked = host.pick(list, event); - self.absorb_external(); - return if self.aborted() { - None - } else { - picked.and_then(|i| entry.get(i)) - }; - } - let picked = { - let core = self.core.borrow(); - let params = GuardParams { - core: &core, - inner: self, - event, - }; - entry.iter().find(|t| { - t.guard - .as_ref() - .is_none_or(|g| eval_guard(g, ¶ms) && !self.aborted()) - || self.aborted() - }) + let core = self.core.borrow(); + let params = GuardParams { + core: &core, + inner: self, + event, }; - if self.aborted() { None } else { picked } + entry + .iter() + .find(|t| t.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) } fn apply(&self, t: &Transition, event: Option<&T::Event>) { - let cur = self.core.borrow().state; + let (cur, running) = { + let core = self.core.borrow(); + (core.state, core.running) + }; let next = t.target.unwrap_or(cur); let leaving = next != cur; - // A leaving transition with a host id runs whole on the host, in one call. - #[cfg(feature = "host")] - if leaving - && let Some(id) = t.host_id - && let Some(host) = self.host() - { - self.host_transition(host, id, cur, next, event); - return; - } - self.apply_steps(t, event, cur, next); - if leaving && let Some(host) = self.host() { - host.settle(); - } - } - - fn apply_steps( - &self, - t: &Transition, - event: Option<&T::Event>, - cur: T::State, - next: T::State, - ) { - let leaving = next != cur; - let running = self.core.borrow().running; let states = &self.config.0.states; if leaving { if running { self.stop_effects(); - if self.aborted() { - return; - } } self.run_actions(&states[cur.index()].exit, event); - if self.aborted() { - return; - } } self.run_actions(&t.actions, event); - if self.aborted() { - return; - } if leaving { - // What the host holds back (an effects stop) lands before the switch. - if let Some(host) = self.host() { - host.settle(); - if self.aborted() { - return; - } - } self.core.borrow_mut().set_state(next); self.detect_watched(&self.core.borrow(), 0); self.drain_notifies(); self.run_actions(&states[next.index()].entry, event); - if self.aborted() { - return; - } if self.core.borrow().running { self.start_effects(next, event); } } } - /// A leaving transition the host runs whole, in one call. - #[cfg(feature = "host")] - fn host_transition( - &self, - host: &Rc>, - id: u32, - cur: T::State, - next: T::State, - event: Option<&T::Event>, - ) { - let running = self.core.borrow().running; - let stop = running && self.stop_own_effects(); - if self.aborted() { - return; - } - let outcome = host.transition(id, cur, next, event, stop, running); - self.absorb_external(); - if outcome == HostTransition::FailedBefore { - return; - } - let node = &self.config.0.states[next.index()]; - let started = matches!( - outcome, - HostTransition::Done | HostTransition::FailedInEffects - ); - let schedule = { - let mut core = self.core.borrow_mut(); - core.switch_state(next); - let schedule = started && core.running; - if schedule { - core.host_effects = !node.effects.is_empty(); - if node.after.is_empty() { - core.entry_counter += 1; // a new entry, with no timer to stamp - } - } - schedule - }; - if !self.config.0.watch.is_empty() { - self.detect_watched(&self.core.borrow(), 0); - } - // The host announced the switch; the engine's own subscribers still hear it. - if let Some(bus) = self.bus.get() { - bus.notify(); - } - if schedule && !node.after.is_empty() { - self.schedule_after(next, event); - } - } - pub(crate) fn run_actions(&self, actions: &[Action], event: Option<&T::Event>) { for action in actions { self.run_action(action, event); - if self.aborted() { - return; - } } } @@ -859,34 +583,10 @@ impl Inner { .iter() .find(|b| b.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) }; - if self.aborted() { - return; - } if let Some(branch) = picked { self.run_actions(&branch.actions, event); } } - #[cfg(feature = "host")] - Action::External(id) => { - // No borrow held: the host may read computed values, mark changes, send. - if let Some(host) = self.host() { - host.action(*id, event); - } else { - missing("action host for", &id.to_string()); - } - self.absorb_external(); - self.drain_notifies(); - } - #[cfg(feature = "host")] - Action::ExternalList(list) => { - if let Some(host) = self.host() { - host.actions(*list, event); - } else { - missing("action host for list", &list.to_string()); - } - self.absorb_external(); - self.drain_notifies(); - } } } @@ -903,46 +603,19 @@ impl Inner { self.drain_notifies(); } - /// Apply context changes a host marked while the core was borrowed. - fn absorb_external(&self) { - let mask = self.external_mask.replace(0); - if mask == 0 { - return; - } - self.core.borrow_mut().stamp_fields(mask); - self.detect_watched(&self.core.borrow(), mask); - } - /// Deliver the notifications owed by the changes made in the last borrow — once per /// effective change, with no borrow held so observers can read and send. pub(crate) fn drain_notifies(&self) { - let (n, state, mut changes) = { - let mut core = self.core.borrow_mut(); - ( - std::mem::take(&mut core.pending_notifies), - core.state, - std::mem::take(&mut core.notify_changes), - ) - }; - if n == 0 { - return; - } - let host = self.host.get(); - let bus = self.bus.get(); - for _ in 0..n { - if let Some(bus) = bus { + let n = std::mem::take(&mut self.core.borrow_mut().pending_notifies); + if let Some(bus) = self.bus.get() { + for _ in 0..n { bus.notify(); } - if let Some(host) = &host { - host.notify(state, std::mem::take(&mut changes)); - } } } fn start_effects(&self, state: T::State, event: Option<&T::Event>) { - if !self.schedule_after(state, event) { - return; - } + self.schedule_after(state, event); let node = &self.config.0.states[state.index()]; for effect in &node.effects { let cleanup = match effect { @@ -954,33 +627,16 @@ impl Inner { continue; } }, - // No borrow held: the host may read computed values, mark changes, send. - #[cfg(feature = "host")] - Effect::External(id) => { - match self.host() { - Some(host) => { - self.core.borrow_mut().host_effects = true; - host.effect(*id, event); - } - None => missing("effect host for", &id.to_string()), - } - None - } }; if let Some(cleanup) = cleanup { self.core.borrow_mut().state_cleanups.push(cleanup); } - self.absorb_external(); self.drain_notifies(); - if self.aborted() { - return; - } } } /// Enter `state`'s timed transitions: a new entry generation, one timer per `after`. - /// Returns false when a host delay failed. - fn schedule_after(&self, state: T::State, event: Option<&T::Event>) -> bool { + fn schedule_after(&self, state: T::State, event: Option<&T::Event>) { let node = &self.config.0.states[state.index()]; let generation = { let mut core = self.core.borrow_mut(); @@ -1001,18 +657,7 @@ impl Inner { 0 } }, - #[cfg(feature = "host")] - Delay::External(id) => match self.host() { - Some(host) => host.delay(*id, event), - None => { - missing("delay host for", &id.to_string()); - 0 - } - }, }; - if self.aborted() { - return false; - } let mut core = self.core.borrow_mut(); let id = core.next_timer_id; core.next_timer_id = core.next_timer_id.wrapping_add(1).max(1); @@ -1025,7 +670,6 @@ impl Inner { }); core.commands.push(Command::StartTimer { id, ms }); } - true } fn call_effect( @@ -1043,32 +687,18 @@ impl Inner { } /// Cancel the state's timers and run its effect cleanups. A failing cleanup must not - /// leak the others: finish the pass, then report the first failure (a Rust panic is - /// resumed; a host failure is left for the binding to rethrow). + /// leak the others: finish the pass, then resume the first panic. fn stop_effects(&self) { - if self.stop_own_effects() - && let Some(host) = self.host() - { - host.stop_effects(); - } - } - - /// Cancel the state's timers and run the engine's own cleanups. Returns whether the - /// host has effects to stop too. - fn stop_own_effects(&self) -> bool { - let (mut cleanups, host_effects) = { + let mut cleanups = { let mut core = self.core.borrow_mut(); let core = &mut *core; - if core.timers.is_empty() && core.state_cleanups.is_empty() && !core.host_effects { - return false; + if core.timers.is_empty() && core.state_cleanups.is_empty() { + return; } for t in core.timers.drain(..) { core.commands.push(Command::CancelTimer { id: t.id }); } - ( - std::mem::take(&mut core.state_cleanups), - std::mem::replace(&mut core.host_effects, false), - ) + std::mem::take(&mut core.state_cleanups) }; let mut first_panic = None; for cleanup in cleanups.drain(..) { @@ -1086,7 +716,6 @@ impl Inner { if let Some(payload) = first_panic { std::panic::resume_unwind(payload); } - host_effects } /// Seed each computed watch with its value's change stamp. A field watch needs no @@ -1148,14 +777,6 @@ pub(crate) fn eval_guard(guard: &Guard, params: &GuardParams<'_, T> Guard::And(gs) => gs.iter().all(|g| eval_guard(g, params)), Guard::Or(gs) => gs.iter().any(|g| eval_guard(g, params)), Guard::Not(g) => !eval_guard(g, params), - #[cfg(feature = "host")] - Guard::External(id) => match params.inner.host() { - Some(host) => host.guard(*id, params.event), - None => { - missing("guard host for", &id.to_string()); - false - } - }, } } diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index d882887..f86ec17 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -1,6 +1,5 @@ -//! The handle protocol the bindings speak (wasm, React Native over JSI): one change mask -//! per call, and timer commands as flat numbers. The TS adapter decodes every binding -//! with the same code, so the layout lives here, once. +//! The handle protocol a binding speaks (React Native over JSI): one change mask per +//! call, and timer commands as flat numbers. The layout lives here, once. use crate::machine::Machine; use crate::timers::Command; diff --git a/crates/core/src/timers.rs b/crates/core/src/timers.rs index cd201f5..a4d6380 100644 --- a/crates/core/src/timers.rs +++ b/crates/core/src/timers.rs @@ -1,8 +1,8 @@ /// Identifies one scheduled `after` timer. pub type TimerId = u32; -/// What the sans-IO core asks its host to do. The core owns no clock: a host (a JS -/// adapter, a native event loop, a test clock) runs these and calls +/// What the sans-IO core asks its host to do. The core owns no clock: a host (a +/// native event loop, a binding, a test clock) runs these and calls /// `Machine::fire_timer(id)` when a timer comes due. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Command { diff --git a/crates/core/src/ts.rs b/crates/core/src/ts.rs deleted file mode 100644 index b80131f..0000000 --- a/crates/core/src/ts.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! TypeScript types of the values a machine shows JS, so a binding can generate a Rust -//! machine's TS types from its Rust ones. The mapping follows how `dunky-wasm` moves -//! values through serde: `None` is `null`, sequences are arrays, maps are `Map`s, and -//! every number is a `number`. - -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; -use std::rc::Rc; -use std::sync::Arc; - -/// The TypeScript type of a value as JS sees it. `#[derive(TsType)]` implements it for -/// your own data types; `#[derive(State, Event, Context)]` implement it for the machine's. -pub trait TsType { - /// The type, written inline: `string`, `{ id: string; hint?: string }`, `number[]`. - fn ts() -> String; -} - -macro_rules! literal { - ($ts:literal: $($ty:ty),*) => { - $(impl TsType for $ty { - fn ts() -> String { - $ts.into() - } - })* - }; -} - -literal!("number": u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64); -literal!("string": String, str, char); -literal!("boolean": bool); -literal!("null": ()); - -macro_rules! same_as_inner { - ($($ty:ident),*) => { - $(impl TsType for $ty { - fn ts() -> String { - T::ts() - } - })* - }; -} - -same_as_inner!(Box, Rc, Arc); - -impl TsType for &T { - fn ts() -> String { - T::ts() - } -} - -impl TsType for Option { - fn ts() -> String { - format!("{} | null", T::ts()) - } -} - -macro_rules! sequence { - ($($ty:ident),*) => { - $(impl TsType for $ty { - fn ts() -> String { - array(&T::ts()) - } - })* - }; -} - -sequence!(Vec, VecDeque, HashSet, BTreeSet); - -impl TsType for [T] { - fn ts() -> String { - array(&T::ts()) - } -} - -impl TsType for [T; N] { - fn ts() -> String { - array(&T::ts()) - } -} - -impl TsType for HashMap { - fn ts() -> String { - format!("Map<{}, {}>", K::ts(), V::ts()) - } -} - -impl TsType for BTreeMap { - fn ts() -> String { - format!("Map<{}, {}>", K::ts(), V::ts()) - } -} - -macro_rules! tuple { - ($($name:ident),+) => { - impl<$($name: TsType),+> TsType for ($($name,)+) { - fn ts() -> String { - let items: &[String] = &[$($name::ts()),+]; - format!("[{}]", items.join(", ")) - } - } - }; -} - -tuple!(A); -tuple!(A, B); -tuple!(A, B, C); -tuple!(A, B, C, D); - -/// `T[]`, or `Array` when `T` is a union or an object. -fn array(item: &str) -> String { - if item.contains(' ') { - format!("Array<{item}>") - } else { - format!("{item}[]") - } -} - -/// An object key: as is when it is an identifier, else quoted. -#[doc(hidden)] -pub fn key(name: &str) -> String { - let mut chars = name.chars(); - let identifier = chars - .next() - .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$'); - if identifier { - name.to_owned() - } else { - literal(name) - } -} - -/// A string literal type: `'name'`. -#[doc(hidden)] -pub fn literal(value: &str) -> String { - format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) -} - -/// An object type from its members (`key: type` or `key?: type`). -#[doc(hidden)] -pub fn object(members: &[String]) -> String { - if members.is_empty() { - "{}".into() - } else { - format!("{{ {} }}", members.join("; ")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maps_values_the_way_they_reach_js() { - assert_eq!(>::ts(), "string | null"); - assert_eq!(>::ts(), "number[]"); - assert_eq!(>>::ts(), "Array"); - assert_eq!(>::ts(), "Map"); - assert_eq!(<(u8, String)>::ts(), "[number, string]"); - } - - #[test] - fn quotes_keys_and_literals_only_when_needed() { - assert_eq!(key("activeId"), "activeId"); - assert_eq!(key("query.set"), "'query.set'"); - assert_eq!(literal("it's"), "'it\\'s'"); - assert_eq!(object(&[]), "{}"); - } -} diff --git a/crates/core/tests/derive.rs b/crates/core/tests/derive.rs index 753c90c..f5e83fc 100644 --- a/crates/core/tests/derive.rs +++ b/crates/core/tests/derive.rs @@ -1,8 +1,8 @@ //! Rust-only: the derive macros name events exactly like serde, so the JS side can send //! `{ type, ... }` objects that both decoders (serde and `DeserializeEvent`) accept. -use dunky_core::{Context, DeserializeEvent, Event, EventEnum, State, TsType}; -use serde::{Deserialize, Serialize}; +use dunky_core::{DeserializeEvent, Event, EventEnum}; +use serde::Deserialize; #[derive(Event, Clone, Debug, PartialEq, Deserialize)] #[event(deserialize)] @@ -39,50 +39,3 @@ fn payload_decoding_follows_the_variant_field_renames() { ); assert_eq!(event.unwrap(), Picker::ItemPick { item_id: 3 }); } - -// Only their TS types are read. -#[allow(dead_code)] -#[derive(TsType, Serialize, Clone, PartialEq, Debug)] -struct Item { - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - hint: Option, - note: Option, -} - -#[allow(dead_code)] -#[derive(TsType, Serialize, Clone, Copy, PartialEq, Debug)] -#[serde(rename_all = "lowercase")] -enum Direction { - Up, - Down, -} - -#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] -enum Mode { - Idle, - HalfOpen, -} - -#[derive(Context, Clone, PartialEq, Debug)] -#[context(serialize)] -struct Ctx { - items: Vec, - active_index: u32, - direction: Option, -} - -#[test] -fn ts_types_follow_the_js_names_and_values() { - assert_eq!(Mode::ts(), "'idle' | 'halfOpen'"); - assert_eq!( - Picker::ts(), - "{ type: 'query_set'; query: string } | { type: 'item_pick'; itemId: number } \ - | { type: 'picker.close' }" - ); - assert_eq!( - Ctx::ts(), - "{ items: Array<{ id: string; hint?: string; note: string | null }>; \ - activeIndex: number; direction: 'up' | 'down' | null }" - ); -} diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 3e0c26c..3139212 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dunky-macros" -description = "Derive macros for dunky-core: State, Event, Context, TsType." +description = "Derive macros for dunky-core: State, Event, Context." version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 6e2ea93..08b42cf 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -1,7 +1,5 @@ //! Derive macros for `dunky-core`. Use them through the re-exports in `dunky_core`. -mod ts; - use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -43,18 +41,6 @@ pub fn derive_context(input: TokenStream) -> TokenStream { .into() } -/// `#[derive(TsType)]` on a data type a machine shows JS (a context field, an event -/// payload, a computed value): its TypeScript type, from the serde attributes it crosses -/// with. Supports structs with named fields, newtypes, unit enums (string unions), and -/// internally tagged enums (`#[serde(tag = "...")]`). -#[proc_macro_derive(TsType)] -pub fn derive_ts(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - ts::expand(input) - .unwrap_or_else(|e| e.to_compile_error()) - .into() -} - fn camel(ident: &Ident) -> String { let s = ident.to_string(); let s = s.strip_prefix("r#").unwrap_or(&s); @@ -113,7 +99,7 @@ fn name_attr(attrs: &[Attribute], attr: &str, serde_too: bool) -> syn::Result Option { +fn serde_case(variant: &str, rule: &str) -> Option { let snake = || { let mut out = String::new(); for (i, ch) in variant.char_indices() { @@ -205,15 +191,8 @@ fn expand_state(input: DeriveInput) -> syn::Result { variants.push(&v.ident); } let indices: Vec = (0..variants.len()).collect(); - let literals: Vec = names - .iter() - .map(|n| quote!(::dunky_core::__private::ts_literal(#n))) - .collect(); - let ts_impl = ts::impl_ts(&input, ts::union(&literals)); let (impl_g, ty_g, where_g) = input.generics.split_for_impl(); Ok(quote! { - #ts_impl - impl #impl_g ::dunky_core::StateEnum for #name #ty_g #where_g { const NAMES: &'static [&'static str] = &[#(#names),*]; fn index(self) -> usize { @@ -306,28 +285,6 @@ fn expand_event(input: DeriveInput) -> syn::Result { }); } let indices: Vec = (0..variants.len()).collect(); - let ts_impl = if deserialize { - let mut types = Vec::new(); - for (v, kind_name) in data.variants.iter().zip(&names) { - let fields_rule = - serde_str(&v.attrs, "rename_all")?.or_else(|| rename_all_fields.clone()); - let mut members = vec![ts::tag_member("type", kind_name)]; - match &v.fields { - Fields::Unit => {} - Fields::Named(fields) => members.extend(ts::members( - &fields.named, - fields_rule.as_deref(), - ts::Direction::FromJs, - )?), - // JS cannot send it: no field names to read. - Fields::Unnamed(_) => continue, - } - types.push(ts::object(&members)); - } - ts::impl_ts(&input, ts::union(&types)) - } else { - quote! {} - }; let deserialize_impl = if deserialize { quote! { impl ::dunky_core::DeserializeEvent for #name { @@ -377,8 +334,6 @@ fn expand_event(input: DeriveInput) -> syn::Result { } #deserialize_impl - - #ts_impl }) } @@ -434,24 +389,6 @@ fn expand_context(input: DeriveInput) -> syn::Result { let indices: Vec = (0..idents.len()).collect(); let bits: Vec = indices.iter().map(|i| 1u64 << i).collect(); - let ts_impl = if serialize { - let members: Vec = js_names - .iter() - .zip(&tys) - .map(|(js, ty)| { - quote! { - ::std::format!( - "{}: {}", - ::dunky_core::__private::ts_key(#js), - <#ty as ::dunky_core::TsType>::ts(), - ) - } - }) - .collect(); - ts::impl_ts(&input, ts::object(&members)) - } else { - quote! {} - }; let serialize_impl = if serialize { quote! { impl ::dunky_core::SerializeFields for #name { @@ -542,7 +479,5 @@ fn expand_context(input: DeriveInput) -> syn::Result { } #serialize_impl - - #ts_impl }) } diff --git a/crates/macros/src/ts.rs b/crates/macros/src/ts.rs deleted file mode 100644 index 72acf94..0000000 --- a/crates/macros/src/ts.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! TypeScript types for the derives: `#[derive(TsType)]` on data types, and the -//! `TsType` impls `State`, `Event` and `Context` emit. Names follow the serde attributes -//! the value crosses with, so the TS type matches the JS value. - -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{ - Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, Ident, Lit, - PathArguments, Type, -}; - -use crate::serde_case; - -/// The serde attributes that shape a value's JS form. -#[derive(Default)] -pub(crate) struct Serde { - pub(crate) rename: Option, - pub(crate) rename_all: Option, - pub(crate) rename_all_fields: Option, - pub(crate) tag: Option, - skip: bool, - skip_serializing: bool, - skip_deserializing: bool, - skip_serializing_if: Option, - default: bool, - transparent: bool, -} - -pub(crate) fn serde_attrs(attrs: &[Attribute]) -> syn::Result { - let mut out = Serde::default(); - for a in attrs.iter().filter(|a| a.path().is_ident("serde")) { - a.parse_nested_meta(|meta| { - let name = meta - .path - .get_ident() - .map(Ident::to_string) - .unwrap_or_default(); - let value = if meta.input.peek(syn::Token![=]) { - match meta.value()?.parse::()? { - Expr::Lit(ExprLit { - lit: Lit::Str(s), .. - }) => Some(s.value()), - _ => None, - } - } else if meta.input.peek(syn::token::Paren) { - if matches!(name.as_str(), "rename" | "rename_all") { - return Err( - meta.error("TsType: split serialize/deserialize names are not supported") - ); - } - let _content; - syn::parenthesized!(_content in meta.input); - None - } else { - None - }; - match name.as_str() { - "rename" => out.rename = value, - "rename_all" => out.rename_all = value, - "rename_all_fields" => out.rename_all_fields = value, - "tag" => out.tag = value, - "skip" => out.skip = true, - "skip_serializing" => out.skip_serializing = true, - "skip_deserializing" => out.skip_deserializing = true, - "skip_serializing_if" => out.skip_serializing_if = value, - "default" => out.default = true, - "transparent" => out.transparent = true, - "flatten" | "untagged" | "content" => { - return Err(meta.error(format!("TsType: `{name}` is not supported"))); - } - _ => {} - } - Ok(()) - })?; - } - Ok(out) -} - -/// Which way a value crosses: shown to JS (serialized) or sent from JS (deserialized). -#[derive(Clone, Copy)] -pub(crate) enum Direction { - ToJs, - FromJs, -} - -/// serde's `rename_all` rules for a (snake_case) field name, like serde applies them. -fn field_case(field: &str, rule: &str) -> Option { - let pascal = || { - field - .split('_') - .map(|part| { - let mut chars = part.chars(); - chars - .next() - .map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()) - .unwrap_or_default() - }) - .collect::() - }; - Some(match rule { - "lowercase" | "snake_case" => field.to_owned(), - "UPPERCASE" | "SCREAMING_SNAKE_CASE" => field.to_ascii_uppercase(), - "PascalCase" => pascal(), - "camelCase" => { - let pascal = pascal(); - let mut chars = pascal.chars(); - chars - .next() - .map(|c| c.to_ascii_lowercase().to_string() + chars.as_str()) - .unwrap_or_default() - } - "kebab-case" => field.replace('_', "-"), - "SCREAMING-KEBAB-CASE" => field.replace('_', "-").to_ascii_uppercase(), - _ => return None, - }) -} - -fn unknown_rule(span: &impl quote::ToTokens, rule: &str) -> syn::Error { - syn::Error::new_spanned(span, format!("unknown rename_all rule \"{rule}\"")) -} - -/// The inner type of `Option`, read from the syntax. -fn option_inner(ty: &Type) -> Option<&Type> { - let Type::Path(path) = ty else { return None }; - let last = path.path.segments.last()?; - if last.ident != "Option" { - return None; - } - let PathArguments::AngleBracketed(args) = &last.arguments else { - return None; - }; - match args.args.first()? { - GenericArgument::Type(inner) => Some(inner), - _ => None, - } -} - -/// The TS members of named fields (`key: type` or `key?: type`), as expressions that -/// build them. `rename_all` is the rule the fields' container applies. -pub(crate) fn members<'a>( - fields: impl IntoIterator, - rename_all: Option<&str>, - direction: Direction, -) -> syn::Result> { - let mut out = Vec::new(); - for f in fields { - let attrs = serde_attrs(&f.attrs)?; - let skipped = attrs.skip - || match direction { - Direction::ToJs => attrs.skip_serializing, - Direction::FromJs => attrs.skip_deserializing, - }; - if skipped { - continue; - } - let ident = f.ident.as_ref().expect("named field").to_string(); - let ident = ident.strip_prefix("r#").unwrap_or(&ident); - let name = match (&attrs.rename, rename_all) { - (Some(name), _) => name.clone(), - (None, Some(rule)) => field_case(ident, rule).ok_or_else(|| unknown_rule(f, rule))?, - (None, None) => ident.to_owned(), - }; - let none_skipped = attrs.skip_serializing_if.as_deref() == Some("Option::is_none"); - let (optional, ty) = match direction { - // `None` never crosses as `null` when serde skips it. - Direction::ToJs => match option_inner(&f.ty) { - Some(inner) if none_skipped => (true, inner), - _ => (attrs.skip_serializing_if.is_some(), &f.ty), - }, - // serde reads a missing `Option` field as `None`. - Direction::FromJs => (attrs.default || option_inner(&f.ty).is_some(), &f.ty), - }; - let mark = if optional { "?" } else { "" }; - out.push(quote! { - ::std::format!( - "{}{}: {}", - ::dunky_core::__private::ts_key(#name), - #mark, - <#ty as ::dunky_core::TsType>::ts(), - ) - }); - } - Ok(out) -} - -/// An object type from member expressions. -pub(crate) fn object(members: &[TokenStream2]) -> TokenStream2 { - quote!(::dunky_core::__private::ts_object(&[#(#members),*])) -} - -/// The member that names a variant of a tagged union: `type: 'open'`. -pub(crate) fn tag_member(tag: &str, name: &str) -> TokenStream2 { - quote! { - ::std::format!( - "{}: {}", - ::dunky_core::__private::ts_key(#tag), - ::dunky_core::__private::ts_literal(#name), - ) - } -} - -/// Join type expressions into a union (`never` when there are none). -pub(crate) fn union(types: &[TokenStream2]) -> TokenStream2 { - if types.is_empty() { - return quote!(::std::string::String::from("never")); - } - quote!([#(#types),*].join(" | ")) -} - -/// `impl TsType` with the body `ts`, bounding every type parameter by `TsType`. -pub(crate) fn impl_ts(input: &DeriveInput, ts: TokenStream2) -> TokenStream2 { - let name = &input.ident; - let mut generics = input.generics.clone(); - for param in generics.type_params_mut() { - param.bounds.push(syn::parse_quote!(::dunky_core::TsType)); - } - let (impl_g, ty_g, where_g) = generics.split_for_impl(); - quote! { - impl #impl_g ::dunky_core::TsType for #name #ty_g #where_g { - fn ts() -> ::std::string::String { - #ts - } - } - } -} - -/// `#[derive(TsType)]`: the value as JS reads it (serialized with serde). -pub(crate) fn expand(input: DeriveInput) -> syn::Result { - let container = serde_attrs(&input.attrs)?; - let ts = match &input.data { - Data::Struct(data) => match &data.fields { - Fields::Named(fields) if !container.transparent => object(&members( - &fields.named, - container.rename_all.as_deref(), - Direction::ToJs, - )?), - Fields::Named(fields) if fields.named.len() == 1 => { - let ty = &fields.named[0].ty; - quote!(<#ty as ::dunky_core::TsType>::ts()) - } - Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { - let ty = &fields.unnamed[0].ty; - quote!(<#ty as ::dunky_core::TsType>::ts()) - } - Fields::Unit => quote!(::std::string::String::from("null")), - _ => { - return Err(syn::Error::new_spanned( - &input.ident, - "TsType: use named fields, or one field (a newtype)", - )); - } - }, - Data::Enum(data) => { - let mut variants = Vec::new(); - for v in &data.variants { - let attrs = serde_attrs(&v.attrs)?; - if attrs.skip || attrs.skip_serializing { - continue; - } - let ident = v.ident.to_string(); - let name = match (&attrs.rename, &container.rename_all) { - (Some(name), _) => name.clone(), - (None, Some(rule)) => { - serde_case(&ident, rule).ok_or_else(|| unknown_rule(&v.ident, rule))? - } - (None, None) => ident, - }; - let fields_rule = attrs - .rename_all - .as_deref() - .or(container.rename_all_fields.as_deref()); - variants.push(match (&container.tag, &v.fields) { - (None, Fields::Unit) => quote!(::dunky_core::__private::ts_literal(#name)), - (Some(tag), Fields::Unit) => object(&[tag_member(tag, &name)]), - (Some(tag), Fields::Named(fields)) => { - let mut all = vec![tag_member(tag, &name)]; - all.extend(members(&fields.named, fields_rule, Direction::ToJs)?); - object(&all) - } - _ => { - return Err(syn::Error::new_spanned( - v, - "TsType: an enum with data needs #[serde(tag = \"...\")] and named fields", - )); - } - }); - } - union(&variants) - } - Data::Union(_) => { - return Err(syn::Error::new_spanned( - &input.ident, - "TsType: unions are not supported", - )); - } - }; - Ok(impl_ts(&input, ts)) -} diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml new file mode 100644 index 0000000..d539ef5 --- /dev/null +++ b/crates/sandbox/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-sandbox" +description = "The sandbox machines written in Rust (the command palette, a dialog): twins of the TS ones." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +dunky-core = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/sandbox/shared/rust/src/dialog.rs b/crates/sandbox/src/dialog.rs similarity index 100% rename from sandbox/shared/rust/src/dialog.rs rename to crates/sandbox/src/dialog.rs diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs new file mode 100644 index 0000000..7e48ffa --- /dev/null +++ b/crates/sandbox/src/lib.rs @@ -0,0 +1,11 @@ +//! The sandbox machines, written in Rust against `dunky-core` — the Rust twins of the TS +//! machines in `sandbox/shared/src`, following the same spec: +//! +//! - [`palette`] — the command palette, a faithful port of `sandbox/shared/src/machine.ts` +//! (computed filtering, logical navigation). +//! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). +//! +//! `crates/uniffi` runs the palette on React Native. + +pub mod dialog; +pub mod palette; diff --git a/sandbox/shared/rust/src/palette.rs b/crates/sandbox/src/palette.rs similarity index 97% rename from sandbox/shared/rust/src/palette.rs rename to crates/sandbox/src/palette.rs index cb6a8e5..cb06d04 100644 --- a/sandbox/shared/rust/src/palette.rs +++ b/crates/sandbox/src/palette.rs @@ -4,10 +4,10 @@ //! wraparound, clamp) lives in context + computed, driven by logical events. A renderer //! never computes an index: it sends `move` / `query.set` and reads `results` / `activeId`. -use dunky_core::{ComputedKey, Config, Context, Event, State, TsType, Types}; +use dunky_core::{ComputedKey, Config, Context, Event, State, Types}; use serde::{Deserialize, Serialize}; -#[derive(TsType, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Command { pub id: String, pub label: String, @@ -18,7 +18,7 @@ pub struct Command { } /// Stamped on every execute; the nonce makes each execution a distinct value. -#[derive(TsType, Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct Executed { pub id: String, pub nonce: u32, @@ -30,7 +30,7 @@ pub enum PaletteState { Open, } -#[derive(TsType, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MoveTo { Up, diff --git a/crates/uniffi/Cargo.toml b/crates/uniffi/Cargo.toml index 3f84878..befe3a7 100644 --- a/crates/uniffi/Cargo.toml +++ b/crates/uniffi/Cargo.toml @@ -17,7 +17,7 @@ crate-type = ["cdylib", "staticlib"] [dependencies] dunky-core = { path = "../core" } -dunky-sandbox = { path = "../../sandbox/shared/rust" } +dunky-sandbox = { path = "../sandbox" } serde_json = "1" # Must stay on the uniffi minor that uniffi-bindgen-react-native is built with # (ubrn 0.31.0-6 -> uniffi 0.31): the generated C++/TS check the contract version. diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml deleted file mode 100644 index 0de250e..0000000 --- a/crates/wasm/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "dunky-wasm" -description = "Run dunky-core machines from JavaScript: export a Rust machine with one macro; the runtime for TS-authored machines." -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[features] -# The runtime for TS-authored machines (JsConfig, JsMachine): what -# @dunky.dev/state-machine ships as its wasm. -runtime = [] - -[dependencies] -dunky-core = { workspace = true, features = ["host"] } -wasm-bindgen = { workspace = true } -js-sys = { workspace = true } -serde = { workspace = true } -serde-wasm-bindgen = { workspace = true } diff --git a/crates/wasm/README.md b/crates/wasm/README.md deleted file mode 100644 index b40af18..0000000 --- a/crates/wasm/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# dunky-wasm - -Run [`dunky-core`](../core) machines from JavaScript. Export a machine written in Rust -with one macro; wrap an instance with `fromWasm` from `@dunky.dev/state-machine`, and the -connector, `useMachine` and every target consume it like a machine written in TS. - -```rust -use dunky_core::{Config, Machine}; -use wasm_bindgen::prelude::*; - -thread_local!(static CONFIG: Config = palette::config(Vec::new())); - -dunky_wasm::export_machine! { - /// The command palette. - pub struct PaletteMachine(palette::Palette); - new(commands: JsValue) { - // A Result body throws its error from the JS constructor. - dunky_wasm::from_js(commands) - .map(|c| CONFIG.with(|config| Machine::with_context(config, palette::context(c)))) - } - computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } - // Optional: served too, but left out of the machine's TS types. - internal { palette::RESULT_INDICES => Vec } -} -``` - -Build it, add its TS types, then wrap it: - -```bash -cargo build --target wasm32-unknown-unknown --release -wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg target/wasm32-unknown-unknown/release/my_machines.wasm -# Natively, PaletteMachine::typescript(&config) returns the class's TS types, generated -# from the Rust types: print them from a small binary or example, onto the .d.ts. -cargo run --example typescript >> pkg/my_machines.d.ts -``` - -```ts -import { fromWasm } from '@dunky.dev/state-machine' -import init, { PaletteMachine } from './pkg/my_machines.js' - -await init() -// Machine<'closed' | 'open', { commands: ...; query: string; ... }, ...>: from Rust. -const palette = fromWasm(new PaletteMachine(commands)) -``` - -The data types the machine shows JS derive `TsType` (`#[derive(TsType)]`), next to -their serde derives. - -The crate using the macro also depends on `wasm-bindgen` (the version this crate pins), -and its event type derives `#[event(deserialize)]`. The `runtime` feature is the engine -for machines written in TS; `@dunky.dev/state-machine` ships it. - -What the protocol guarantees: [`SPEC.md`](./SPEC.md). diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md deleted file mode 100644 index 313cbd6..0000000 --- a/crates/wasm/SPEC.md +++ /dev/null @@ -1,162 +0,0 @@ -# SPEC - `dunky-wasm` - -## Overview - -`dunky-wasm` runs [`dunky-core`](../core/SPEC.md) machines from JavaScript. It owns the -**protocol** between the engine, compiled to WebAssembly, and the `Machine` facade in -[`@dunky.dev/state-machine`](../../packages/core/SPEC.md). Two producers speak it: - -- **The runtime** (cargo feature `runtime`) runs machines written in TypeScript. The - core package ships it, built from `crates/core-wasm`. -- **The export macro** turns a machine written in Rust into a JS class, in the author's - own wasm module. The core package wraps an instance with `fromWasm`. - -``` - TS machine (config) Rust machine (dunky-core types) - | | - | compiled to numbers + JS tables | export macro, your crate - v v - runtime (crates/core-wasm) exported class (your wasm module) - | | - +-----------------+-------------------+ - | the protocol - | events in .... by kind number + the JS event - | notify ....... once per change, synchronously - | timers ....... on the host clock - v - the JS host + the Machine facade (packages/core) -``` - -## Intent - -One protocol for both kinds of machine, so the facade, the connector and every target -treat them alike, and the JS side stays thin: Rust runs the graph, the timers' -bookkeeping and the change tracking; JS runs only the user code of a TS machine and the -fan-out to subscribers. - -## Scope & boundaries - -**In scope.** The protocol; the runtime for TS machines; the export macro for Rust -machines; the conversion helpers between JS values and Rust values. - -**Out of scope — invariants, not preferences.** - -- **No facade.** The `Machine` interface, the names (states, event types, fields) and the - subscribers live in the core package. -- **No platform.** Only the JS host's own functions are called: no DOM, no `window`. -- **No strong JS references.** Rust never holds a JS object strongly (see Handles). - -## The protocol - -### The facade stays in JS - -- Rust calls the host only during a call into the machine, so JS keeps the **facade** - whose call is in progress itself (saved and restored around each call, as calls - nest), and every host function runs for it. No reference to the facade crosses: a - call carries only numbers (and the JS event). Rust holds no JS object of a machine, so - no reference cycle through wasm keeps it alive: only JS references do. - -### The host - -- One JS host object serves every machine of a module. Its functions take numbers and - return status codes, so each call crosses the boundary once, with nothing to box or - free. -- The host functions never throw. Each catches what user code throws, keeps the first - error on the facade, and returns a failure status; Rust raises the machine's halt flag - and stops the step. -- `notify(state, changedLo, changedHi)`: once per effective change, in order, - synchronously, with the current state and the context fields changed since the - previous notify (the low and high 32 bits of the mask). -- `startTimer(id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels - it. A due timer calls back into the machine with its id. -- For TS machines only, by index in the JS tables, with the event that caused the call - (none for a boot or a data-reaction): - - `guard`; `pick`, a guard walk in one call (the first candidate that passes); - - `action`; `actions`, a run of actions in one call; - - `effect`: starts an effect, and the facade keeps its cleanup; - - `delay`; - - `computed`: runs a derived definition. The value stays in JS, and the status says - whether it changed (`Object.is`). What the definition read crosses before it - returns, and only when it differs from the previous evaluation; - - `transition`: a leaving transition run whole (see below). -- What the engine holds back rides on the next `action`, `actions`, `effect` or - `transition` call as `pre`, and runs first: bit 0 stops the effects (the facade runs - every cleanup, in start order, even past one that throws), the bits above announce a - state change (the state + 1). With no such call, `pre` is delivered on its own, before - the state switches and when the transition ends. -- A **whole transition** is one call for a TS machine without computed values, when the - target has no named delay: stop the effects, exit actions, the transition's actions, - announce the switch, entry actions, start the target's effects. It returns where it - failed: before the switch (the machine stays put), after it, or while starting the - effects. - -### Calls - -- Each call into a machine runs to completion and returns a status: 0, 1 when user code - failed during this call (the facade holds the error), 3 when the engine failed (its - message comes from `takeFailure`). -- The **outermost** call also runs the timer commands. A **nested** call (user code - calling back in, e.g. a write inside an action) reports only a failure it caused; the - outer call reports it too. JS throws: the outermost call takes the error, a nested one - leaves it for the outer call. -- After a failure the machine stays usable; what was queued stays queued. - -### Timers - -- The engine's timer commands run on the host clock. A pending timer is held by the - host's closure, so it keeps its machine alive, like a JS timer holds `this`. - -### Values - -- **TS machines** keep JS values: the context is a JS object, and a derived value is a - JS value, kept in JS and compared with `Object.is`. -- **Rust machines** convert with serde: `None` becomes `null`. A derived value is - serialized only when its version changed, and the same JS value is served until then, - so its identity marks a change. - -## The runtime — TS machines - -- A config is **compiled** once, on the JS side: states, event types and every callback - become numbers. The compiled config is shared by every machine built from it. -- The context lives in JS, and JS announces its own writes to the subscribers. It - reports a write's change mask to Rust only when the machine has watchers or derived - values: one bit per field, 64 bits, and every field past the 63rd shares the last bit. - Without watchers the report only stamps, so it is a plain call: no status. -- JS keeps each derived value with what its last evaluation read: fields, the state, - other derived values. A write clears the values that read a written field, a state - change clears the values that read the state, and a cleared value clears the values - that read it. JS serves a value that is not cleared without calling Rust; for a - cleared one it asks Rust, which checks again and evaluates only what changed. Every - input of a derived value changes through JS, so the cache misses no change. -- The event object crosses as is: the engine carries it — also for a timed transition - scheduled by that event — and hands it back to the callbacks. - -## The export macro — Rust machines - -- The macro takes the class name, the machine type, a constructor body, and the derived - values the class serves, each with its key and its type; an optional `internal` block - lists derived values it serves but leaves out of its types. A constructor body that - returns an error throws it from the JS constructor; bad arguments read as - `[machine] bad argument: ...`. -- The class implements the protocol: attach to a host (once), dispatch by kind, start, - stop, report whether it runs, report the state, read a context field, read a derived - value, take a due timer, and report the names (states, event types, fields, derived - values, tags per state) the facade maps the numbers onto. -- The class works on its own too: `start()`, `send(kind, event)`. Once attached, call it - through its `fromWasm` facade only: the host functions run for the facade whose call - is in progress. -- An event kind without data is built from the number alone; any other kind is read from - the JS event's fields. The event type opts in to that with `#[event(deserialize)]`. -- The crate using the macro depends on `wasm-bindgen` at the version this crate pins. -- **Types.** Natively, the class reports its TS types (`typescript(&config)`), generated - from the machine's Rust types: the states, the context, the events, and the derived - values outside `internal`. The build appends them to the module's `.d.ts` as a - `__types` member of the class, which exists only in the types; `fromWasm` infers the - machine's types from it. A Rust type change changes the TS types on the next build. - -## Edge cases that carry design meaning - -- Attaching a Rust machine to a host twice is an engine failure. -- An unknown event kind is an error; the facade filters unknown event types before. -- A malformed payload is an error that names the event type. -- Commands emitted before a Rust machine was attached run when it attaches. diff --git a/crates/wasm/src/bridge.rs b/crates/wasm/src/bridge.rs deleted file mode 100644 index 09eb36c..0000000 --- a/crates/wasm/src/bridge.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! What every machine exported to JS shares, TS-authored (the runtime) or Rust -//! (`export_machine!`): the JS host it calls back into, the failure flag of a call, and -//! the host clock that runs its timers. -//! -//! The host is one JS object per module. Its functions take numbers and return status -//! codes, so a call crosses the boundary once, with nothing to box or free. They run for -//! the machine whose call is in progress, which JS keeps track of itself: no reference to -//! the facade crosses, and Rust holds no JS object of a machine, so no JS <-> wasm -//! reference cycle keeps a machine alive. -//! -//! JS owns the errors: a host function catches what user code throws, keeps it, and -//! returns a failure status. Rust only stops the step and reports the status back. - -use std::cell::{Cell, OnceCell, RefCell}; -use std::rc::Rc; - -use dunky_core::{Changes, Command, Machine, Types}; -use wasm_bindgen::prelude::*; - -/// A call finished. -pub const OK: u32 = 0; -/// User code failed during the call; the JS side holds the error. -pub const FAILED: u32 = 1; -/// The engine failed (a feedback loop, a malformed event): read `takeFailure()`. -pub const ENGINE_FAILED: u32 = 3; - -#[wasm_bindgen] -extern "C" { - /// The JS host object. - #[derive(Clone)] - pub type HostObject; - - /// Once per effective change. Returns 0, or 1 when an observer threw. - #[wasm_bindgen(method)] - fn notify(this: &HostObject, state: u32, lo: u32, hi: u32) -> u32; - - /// Returns the host's timeout. - #[wasm_bindgen(method, js_name = startTimer)] - fn start_timer(this: &HostObject, id: u32, ms: u32) -> JsValue; - - #[wasm_bindgen(method, js_name = cancelTimer)] - fn cancel_timer(this: &HostObject, timeout: &JsValue); -} - -/// One machine's line to the JS host. -pub(crate) struct Link { - pub(crate) host: HostObject, - /// The machine's halt flag: a host function failed during the current call. - failed: Rc>, -} - -impl Link { - pub(crate) fn new(host: HostObject, halt: Rc>) -> Self { - Self { host, failed: halt } - } - - pub(crate) fn fail(&self) { - self.failed.set(true); - } - - pub(crate) fn failed(&self) -> bool { - self.failed.get() - } - - pub(crate) fn notify(&self, state: u32, changes: Changes) { - let (lo, hi) = (changes.fields as u32, (changes.fields >> 32) as u32); - if self.host.notify(state, lo, hi) != OK { - self.fail(); - } - } -} - -/// A machine exported to JS: runs each call to completion, then the timer commands, and -/// reports a status. -pub struct Bridge { - machine: Machine, - link: OnceCell>, - /// Pending timers: the engine's timer id, and the host's timeout. - timers: RefCell>, - /// Nesting of calls (JS code calls back in); the outermost one runs the timers. - depth: Cell, - /// The engine's own failure, until JS takes it. - failure: RefCell>, -} - -impl Bridge { - pub fn new(machine: Machine) -> Self { - Self { - machine, - link: OnceCell::new(), - timers: RefCell::default(), - depth: Cell::new(0), - failure: RefCell::new(None), - } - } - - pub fn machine(&self) -> &Machine { - &self.machine - } - - /// Connect to the JS host. Returns false when already connected. - pub(crate) fn connect(&self, link: Rc) -> bool { - self.link.set(link).is_ok() - } - - /// Run `f` on the machine and report a status. The outermost call also runs the - /// timer commands. `FAILED` means user code failed during this very call: a nested - /// call (JS calling back in) does not report a failure from before it. - pub fn call(&self, f: impl FnOnce(&Machine)) -> u32 { - let depth = self.depth.get(); - let link = self.link.get(); - let failed_before = link.is_some_and(|l| l.failed()); - self.depth.set(depth + 1); - f(&self.machine); - self.depth.set(depth); - let failed = link.is_some_and(|l| l.failed()); - if depth > 0 { - return if failed && !failed_before { FAILED } else { OK }; - } - if let Some(link) = link { - self.run_timers(link); - // The flag is the machine's: the engine raises it for its own failure too. - if link.failed.replace(false) { - return match self.machine.take_failure() { - Some(message) => self.fail(message), - None => FAILED, - }; - } - return OK; - } - match self.machine.take_failure() { - Some(message) => self.fail(message), - None => OK, - } - } - - /// Keep the engine's `message` for `take_failure`. - pub fn fail(&self, message: String) -> u32 { - *self.failure.borrow_mut() = Some(message); - ENGINE_FAILED - } - - pub fn take_failure(&self) -> Option { - self.failure.borrow_mut().take() - } - - pub fn start(&self) -> u32 { - self.call(|m| m.start()) - } - - pub fn stop(&self) -> u32 { - self.call(|m| m.stop()) - } - - /// The host clock: timer `id` came due. - pub fn fire(&self, id: u32) -> u32 { - { - let mut timers = self.timers.borrow_mut(); - if let Some(pos) = timers.iter().position(|(t, _)| *t == id) { - timers.swap_remove(pos); - } - } - self.call(|m| m.fire_timer(id)) - } - - /// The core emits timer commands; the host runs them on its clock (`setTimeout`). - pub(crate) fn run_timers(&self, link: &Link) { - if !self.machine.has_commands() { - return; - } - for command in self.machine.take_commands() { - match command { - Command::StartTimer { id, ms } => { - let timeout = link.host.start_timer(id, ms); - self.timers.borrow_mut().push((id, timeout)); - } - Command::CancelTimer { id } => { - let timeout = { - let mut timers = self.timers.borrow_mut(); - let pos = timers.iter().position(|(t, _)| *t == id); - pos.map(|pos| timers.swap_remove(pos).1) - }; - if let Some(timeout) = timeout { - link.host.cancel_timer(&timeout); - } - } - } - } - } -} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs deleted file mode 100644 index 4626c75..0000000 --- a/crates/wasm/src/lib.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! `dunky-wasm` — run `dunky-core` machines from JavaScript. -//! -//! - [`export_machine!`] turns a machine written in Rust into a JS class. The core package -//! (`@dunky.dev/state-machine`) wraps an instance with `fromWasm`, so every target — -//! react, solid, native, opentui — consumes it as a `Machine`. -//! - The `runtime` feature is the engine for machines written in TS: the core package -//! ships it (`crates/core-wasm`). -//! -//! Both speak one protocol with the core package's JS host: events in by kind number, -//! a `notify(state, changedLo, changedHi)` per effective change (synchronously, -//! like the TS subscribers), timers on the host clock (`startTimer` / `cancelTimer`, then -//! `fire(id)`), and a status code back from every call (see [`bridge`]). -//! -//! The crate using the macro must also depend on `wasm-bindgen`. -//! -//! A class also knows its TS types (`typescript`, natively): they come from the machine's -//! Rust types (`TsType`), and `pnpm build:wasm` adds them to the module's `.d.ts`, where -//! `fromWasm` reads them. - -use std::cell::RefCell; -use std::rc::Rc; - -use dunky_core::{ - Changes, ComputedKey, Context, DeserializeEvent, Evaluation, EventEnum, Host, Machine, - SerializeFields, StateEnum, Types, -}; -use serde::Serialize; -use serde::de::DeserializeOwned; -use wasm_bindgen::JsCast; -use wasm_bindgen::prelude::*; - -pub mod bridge; -#[cfg(feature = "runtime")] -pub mod runtime; - -use bridge::Link; -pub use bridge::{Bridge, ENGINE_FAILED, FAILED, HostObject, OK}; - -#[doc(hidden)] -pub mod __private { - pub use dunky_core::{ComputedKey, Config, TsType}; - - use dunky_core::__private::{ts_key, ts_object}; - use dunky_core::{Context, Types}; - - /// The declaration that gives exported class `class` its machine's types: a - /// `__types` member, only in the types, that `fromWasm` reads. `computed` holds the - /// computed values of the types by id, with their TS types. - pub fn class_types( - class: &str, - config: &Config, - computed: &[(usize, String)], - ) -> String - where - T::State: TsType, - T::Event: TsType, - T::Context: TsType, - { - let names: Vec<&str> = config.computed_names().collect(); - let computed: Vec = computed - .iter() - .map(|(id, ts)| format!("{}: {ts}", ts_key(names[*id]))) - .collect(); - // The facade builds the context from the fields: none gives an empty object. - let empty = || String::from("Record"); - let context = if ::FIELDS.is_empty() { - empty() - } else { - ::ts() - }; - let computed = if computed.is_empty() { - empty() - } else { - ts_object(&computed) - }; - format!( - "\nexport interface {class} {{\n /** Generated from the Rust machine's types; `fromWasm` reads them. */\n readonly __types?: {{\n state: {};\n context: {};\n event: {};\n computed: {};\n }};\n}}\n", - ::ts(), - context, - ::ts(), - computed, - ) - } -} - -/// What an [`export_machine!`] constructor body may evaluate to: a machine, or a -/// `Result` whose error is thrown from the JS constructor. -pub trait IntoMachine { - fn into_machine(self) -> Result, JsError>; -} - -impl IntoMachine for Machine { - fn into_machine(self) -> Result, JsError> { - Ok(self) - } -} - -impl IntoMachine for Result, JsError> { - fn into_machine(self) -> Result, JsError> { - self - } -} - -/// Deserialize a JS value (constructor arguments, props). -pub fn from_js(value: JsValue) -> Result { - serde_wasm_bindgen::from_value(value).map_err(|e| { - let error: js_sys::Error = JsValue::from(e).unchecked_into(); - JsError::new(&format!( - "[machine] bad argument: {}", - String::from(error.message()) - )) - }) -} - -// `None` becomes `null`, matching the TS machines (`lastExecuted: null`, `activeId: null`). -fn serializer() -> serde_wasm_bindgen::Serializer { - serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true) -} - -/// Serialize any value for JS (plain objects, arrays, `null` for `None`). -pub fn to_js(value: &V) -> JsValue { - value.serialize(&serializer()).unwrap_or(JsValue::UNDEFINED) -} - -/// A Rust machine's host: it only hears changes (its guards, actions and effects are Rust). -struct Observer { - link: Rc, -} - -impl Host for Observer { - fn guard(&self, _: u32, _: Option<&T::Event>) -> bool { - unreachable!("a Rust machine has no external guards") - } - fn action(&self, _: u32, _: Option<&T::Event>) { - unreachable!("a Rust machine has no external actions") - } - fn effect(&self, _: u32, _: Option<&T::Event>) { - unreachable!("a Rust machine has no external effects") - } - fn stop_effects(&self) {} - fn delay(&self, _: u32, _: Option<&T::Event>) -> u32 { - unreachable!("a Rust machine has no external delays") - } - fn computed(&self, _: u32) -> Evaluation { - unreachable!("a Rust machine has no external computed values") - } - fn pick(&self, _: u32, _: Option<&T::Event>) -> Option { - unreachable!("a Rust machine has no external guards") - } - fn actions(&self, _: u32, _: Option<&T::Event>) { - unreachable!("a Rust machine has no external actions") - } - fn notify(&self, state: T::State, changes: Changes) { - self.link.notify(state.index() as u32, changes); - } - fn settle(&self) {} - fn transition( - &self, - _: u32, - _: T::State, - _: T::State, - _: Option<&T::Event>, - _: bool, - _: bool, - ) -> dunky_core::HostTransition { - unreachable!("a Rust machine has no host transitions") - } -} - -/// The implementation behind every class [`export_machine!`] generates. -pub struct Exported { - bridge: Bridge, - /// Per computed value: the version and the JS value last served. - computed: RefCell>>, -} - -impl Exported -where - T::Event: DeserializeEvent, - T::Context: SerializeFields, -{ - pub fn new(machine: Machine) -> Self { - let count = machine.config().computed_names().count(); - Self { - bridge: Bridge::new(machine), - computed: RefCell::new(vec![None; count]), - } - } - - pub fn machine(&self) -> &Machine { - self.bridge.machine() - } - - /// Connect to the JS host. Returns a status; a second attach is an engine failure. - /// From then on, call the machine through its `fromWasm` facade: the host functions - /// run for the facade whose call is in progress. - pub fn attach(&self, host: HostObject) -> u32 { - let link = Rc::new(Link::new(host, self.machine().halt_flag())); - if !self.bridge.connect(link.clone()) { - return self.bridge.fail("[machine] already attached".into()); - } - self.machine().set_host(Rc::new(Observer { link })); - // Run what the machine did before it had a host (e.g. timers of a start in Rust). - self.bridge.call(|_| {}) - } - - /// Send event `kind`; `event` (the JS event object) supplies the payload, if any. - pub fn send(&self, kind: u32, event: JsValue) -> u32 { - let names = ::KIND_NAMES; - let Some(name) = names.get(kind as usize) else { - return self.bridge.fail(format!("[machine] no event type #{kind}")); - }; - let k = ::kind_from_index(kind as usize); - let event = match ::from_kind(k) { - Some(event) => event, - None => { - let deserializer = serde_wasm_bindgen::Deserializer::from(event); - match ::deserialize_payload( - kind as usize, - deserializer, - ) { - Ok(event) => event, - Err(e) => { - return self - .bridge - .fail(format!("[machine] bad \"{name}\" event: {e}")); - } - } - } - }; - self.bridge.call(|m| m.send(event)) - } - - pub fn start(&self) -> u32 { - self.bridge.start() - } - - pub fn stop(&self) -> u32 { - self.bridge.stop() - } - - pub fn fire(&self, id: u32) -> u32 { - self.bridge.fire(id) - } - - /// The engine's failure behind status 3. - pub fn take_failure(&self) -> Option { - self.bridge.take_failure() - } - - pub fn running(&self) -> bool { - self.machine().is_running() - } - - pub fn state(&self) -> u32 { - self.machine().state().index() as u32 - } - - pub fn field(&self, index: u32) -> JsValue { - self.machine() - .context() - .serialize_field(index as usize, &serializer()) - .unwrap_or(JsValue::UNDEFINED) - } - - /// The value of `key`, serialized only when it changed since the last read. - pub fn computed(&self, key: ComputedKey) -> JsValue { - let version = self.machine().computed_version(key.id()); - if let Some((seen, value)) = &self.computed.borrow()[key.id()] - && *seen == version - { - return value.clone(); - } - let value = to_js(&*self.machine().computed(key)); - self.computed.borrow_mut()[key.id()] = Some((version, value.clone())); - value - } - - /// The names `fromWasm` maps the numbers onto, once per machine type. - pub fn meta(&self) -> JsValue { - #[derive(Serialize)] - struct Meta<'a> { - states: &'a [&'a str], - events: &'a [&'a str], - fields: &'a [&'a str], - computed: Vec<&'a str>, - tags: Vec<&'a [&'a str]>, - } - let config = self.machine().config(); - let states = ::NAMES; - to_js(&Meta { - states, - events: ::KIND_NAMES, - fields: ::FIELDS, - computed: config.computed_names().collect(), - tags: (0..states.len()) - .map(|i| config.tags(::from_index(i))) - .collect(), - }) - } -} - -/// Export a machine type as a JS class. Wrap an instance with `fromWasm` from -/// `@dunky.dev/state-machine`. -/// -/// ```ignore -/// dunky_wasm::export_machine! { -/// /// The command palette. -/// pub struct PaletteMachine(palette::Palette); -/// new(commands: JsValue) { -/// // A `Result` body throws its error from the JS constructor. -/// dunky_wasm::from_js(commands).map(|c| Machine::new(&palette::config(c))) -/// } -/// computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } -/// // Optional: served too, but left out of the machine's TS types. -/// internal { palette::RESULT_INDICES => Vec } -/// } -/// ``` -/// -/// Natively, the class also has `typescript(&config)`: its TS types, from the machine's -/// Rust types, for `pnpm build:wasm` to add to the module's `.d.ts`. -#[macro_export] -macro_rules! export_machine { - ( - $(#[$meta:meta])* - $vis:vis struct $name:ident($types:ty); - new($($arg:ident : $argty:ty),* $(,)?) $make:block - computed { $($key:expr => $cty:ty),* $(,)? } - $(internal { $($ikey:expr => $ity:ty),* $(,)? })? - ) => { - $(#[$meta])* - #[::wasm_bindgen::prelude::wasm_bindgen] - $vis struct $name { - inner: $crate::Exported<$types>, - } - - #[::wasm_bindgen::prelude::wasm_bindgen] - impl $name { - /// Throws when the constructor body returns an error (bad arguments). - #[wasm_bindgen(constructor)] - #[allow(unused_braces)] // `$make` is the caller's block, passed as an argument - pub fn new($($arg: $argty),*) -> ::core::result::Result<$name, ::wasm_bindgen::JsError> { - let machine = $crate::IntoMachine::<$types>::into_machine($make)?; - ::core::result::Result::Ok($name { inner: $crate::Exported::new(machine) }) - } - /// Connect to the JS host (`fromWasm` does). Calls return a status: 0, 1 when - /// JS code failed (the host holds the error), 3 when the engine failed. - pub fn attach(&self, host: $crate::HostObject) -> u32 { - self.inner.attach(host) - } - pub fn send(&self, kind: u32, event: ::wasm_bindgen::JsValue) -> u32 { - self.inner.send(kind, event) - } - pub fn start(&self) -> u32 { - self.inner.start() - } - pub fn stop(&self) -> u32 { - self.inner.stop() - } - pub fn fire(&self, id: u32) -> u32 { - self.inner.fire(id) - } - #[wasm_bindgen(js_name = takeFailure)] - pub fn take_failure(&self) -> ::core::option::Option<::std::string::String> { - self.inner.take_failure() - } - pub fn running(&self) -> bool { - self.inner.running() - } - pub fn state(&self) -> u32 { - self.inner.state() - } - pub fn field(&self, index: u32) -> ::wasm_bindgen::JsValue { - self.inner.field(index) - } - #[allow(unused_variables)] - pub fn computed(&self, id: u32) -> ::wasm_bindgen::JsValue { - $( - let key: $crate::__private::ComputedKey<$cty> = $key; - if id as usize == key.id() { - return self.inner.computed(key); - } - )* - $($( - let key: $crate::__private::ComputedKey<$ity> = $ikey; - if id as usize == key.id() { - return self.inner.computed(key); - } - )*)? - ::wasm_bindgen::JsValue::UNDEFINED - } - pub fn meta(&self) -> ::wasm_bindgen::JsValue { - self.inner.meta() - } - } - - #[cfg(not(target_arch = "wasm32"))] - impl $name { - /// This class's TS types, from the machine's Rust types. - pub fn typescript(config: &$crate::__private::Config<$types>) -> ::std::string::String { - $crate::__private::class_types::<$types>( - ::core::stringify!($name), - config, - &[$({ - let key: $crate::__private::ComputedKey<$cty> = $key; - (key.id(), <$cty as $crate::__private::TsType>::ts()) - }),*], - ) - } - } - }; -} diff --git a/crates/wasm/src/runtime.rs b/crates/wasm/src/runtime.rs deleted file mode 100644 index 98319b0..0000000 --- a/crates/wasm/src/runtime.rs +++ /dev/null @@ -1,614 +0,0 @@ -//! The runtime for TS-authored machines (cargo feature `runtime`). -//! -//! The Rust engine runs the graph: queue, transition resolution, entry/exit order, -//! effects lifecycle, `after` timers, watchers and computed bookkeeping. JS runs the -//! user code — guards, actions, effects, delays, computed definitions — through the -//! host functions (see `crate::bridge`), by the callback's number in the JS-side tables. -//! -//! The context and the computed values live in JS (they hold arbitrary JS values). JS -//! reports which fields a write changed (`markChanged`, or `stamp` without watchers) and -//! what a computed evaluation read (`report`); Rust keeps the change stamps that drive -//! watchers and computed staleness. - -use std::cell::{Cell, RefCell}; -use std::rc::Rc; - -use dunky_core::{ - Action, Branch, Changes, Config, Context, Delay, Effect, Evaluation, EventEnum, Field, Guard, - Host, HostTransition, Machine, Reads, StateEnum, TransitionBuilder, Types, -}; -use serde::Deserialize; -use wasm_bindgen::prelude::*; - -use crate::bridge::{Bridge, FAILED, HostObject, Link, OK}; - -// --------------------------------------------------------------------------------- -// The machine type of every TS-authored machine: states and event types are numbers -// assigned by the JS compiler; the context is owned by JS. - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct DynState(pub u32); - -impl StateEnum for DynState { - // Names live in JS; the engine only needs indices. - const NAMES: &'static [&'static str] = &[]; - fn index(self) -> usize { - self.0 as usize - } - fn from_index(index: usize) -> Self { - DynState(index as u32) - } -} - -/// An event object sent from JS, with its type number. -#[derive(Clone, Debug)] -pub struct JsEvent { - kind: u32, - value: JsValue, -} - -impl EventEnum for JsEvent { - type Kind = u32; - const KIND_NAMES: &'static [&'static str] = &[]; - fn kind(&self) -> u32 { - self.kind - } - fn kind_index(kind: u32) -> usize { - kind as usize - } - fn kind_from_index(index: usize) -> u32 { - index as u32 - } - fn from_kind(_kind: u32) -> Option { - None - } -} - -/// The context of a TS machine lives in JS. Its patch is the change mask JS computed. -#[derive(Clone, Default)] -pub struct JsContext; - -impl Context for JsContext { - type Patch = u64; - // JS evaluates the computed values and reports what they read. - type Reader<'a> = (); - const FIELDS: &'static [&'static str] = &[]; - fn apply(&mut self, changed: u64) -> u64 { - changed - } - fn reader<'a>(&'a self, _deps: &'a Cell) {} -} - -pub struct Js; - -impl Types for Js { - type State = DynState; - type Event = JsEvent; - type Context = JsContext; -} - -// --------------------------------------------------------------------------------- -// The compiled config the JS side sends. Every callback is a number into the JS-side -// tables of that config. - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Spec { - initial: u32, - kinds: u32, - states: Vec, - #[serde(default)] - on_any: Vec, - #[serde(default)] - computed: u32, - #[serde(default)] - watch: Vec, -} - -#[derive(Deserialize, Default)] -#[serde(default)] -struct StateSpec { - on: Vec, - entry: Vec, - exit: Vec, - effects: Vec, - after: Vec, -} - -/// The handler for one event type: candidates in order. With `pick`, the host evaluates -/// their guards in one call (its guard list `pick`). -#[derive(Deserialize)] -struct EntrySpec { - kind: u32, - candidates: Vec, - #[serde(default)] - pick: Option, -} - -#[derive(Deserialize)] -struct AfterSpec { - delay: DelaySpec, - candidates: Vec, - #[serde(default)] - pick: Option, -} - -#[derive(Deserialize)] -struct CandidateSpec { - /// When set, the host runs the transition whole when it leaves the state. - #[serde(default)] - id: Option, - #[serde(default)] - target: Option, - #[serde(default)] - guard: Option, - #[serde(default)] - actions: Vec, -} - -/// `{ id }` for a callback, `{ list }` for a run of callbacks the host runs in one call, -/// `{ oneOf: [...] }` for a conditional. -#[derive(Deserialize)] -struct ActionSpec { - #[serde(default)] - id: Option, - #[serde(default)] - list: Option, - #[serde(default, rename = "oneOf")] - one_of: Option>, -} - -#[derive(Deserialize)] -struct BranchSpec { - #[serde(default)] - guard: Option, - #[serde(default)] - actions: Vec, -} - -/// `{ ms }` for a fixed delay, `{ id }` for a named one. -#[derive(Deserialize)] -struct DelaySpec { - #[serde(default)] - ms: Option, - #[serde(default)] - id: Option, -} - -#[derive(Deserialize)] -struct WatchSpec { - #[serde(default)] - field: Option, - #[serde(default)] - computed: Option, - actions: Vec, -} - -fn action(spec: &ActionSpec) -> Action { - if let Some(branches) = &spec.one_of { - return Action::one_of(branches.iter().map(|b| { - let actions = b.actions.iter().map(action); - match b.guard { - Some(guard) => Branch::when(Guard::External(guard), actions), - None => Branch::otherwise(actions), - } - })); - } - match (spec.list, spec.id) { - (Some(list), _) => Action::ExternalList(list), - (None, Some(id)) => Action::External(id), - (None, None) => Action::one_of([]), - } -} - -fn candidate(mut t: TransitionBuilder, spec: &CandidateSpec) -> TransitionBuilder { - if let Some(id) = spec.id { - t = t.host_id(id); - } - if let Some(target) = spec.target { - t = t.target(DynState(target)); - } - if let Some(guard) = spec.guard { - t = t.guard_expr(Guard::External(guard)); - } - for a in &spec.actions { - t = t.push(action(a)); - } - t -} - -fn build(spec: &Spec) -> Config { - let mut b = Config::::builder_sized( - DynState(spec.initial), - JsContext, - spec.states.len(), - spec.kinds as usize, - ); - for _ in 0..spec.computed { - b.computed_external(""); - } - for (index, state) in spec.states.iter().enumerate() { - b.state(DynState(index as u32), |mut s| { - for entry in &state.on { - for c in &entry.candidates { - s = s.on(entry.kind, |t| candidate(t, c)); - } - if let Some(list) = entry.pick { - s = s.pick(entry.kind, list); - } - } - for a in &state.entry { - s = s.entry(action(a)); - } - for a in &state.exit { - s = s.exit(action(a)); - } - for id in &state.effects { - s = s.effect(Effect::External(*id)); - } - for after in &state.after { - let delay = match (after.delay.ms, after.delay.id) { - (_, Some(id)) => Delay::External(id), - (ms, None) => Delay::Ms(ms.unwrap_or(0)), - }; - let mut rest = after.candidates.iter(); - if let Some(first) = rest.next() { - s = s.after(delay, |t| candidate(t, first)); - for c in rest { - s = s.after_or(|t| candidate(t, c)); - } - if let Some(list) = after.pick { - s = s.after_pick(list); - } - } - } - s - }); - } - for entry in &spec.on_any { - for c in &entry.candidates { - b.on(entry.kind, |t| candidate(t, c)); - } - if let Some(list) = entry.pick { - b.pick_any(entry.kind, list); - } - } - for w in &spec.watch { - let actions = w.actions.iter().map(action).collect::>(); - match (w.field, w.computed) { - (Some(field), _) => { - b.watch(Field::new(field.min(63) as usize), actions); - } - (None, Some(id)) => { - b.watch_computed_id(id as usize, actions); - } - (None, None) => {} - } - } - b.build() -} - -// --------------------------------------------------------------------------------- -// The host: the JS functions run the user code by its index in the JS tables, and -// return status codes (they catch what user code throws and keep it). - -#[wasm_bindgen] -extern "C" { - /// 0 false, 1 true, 2 failed. - #[wasm_bindgen(method)] - fn guard(this: &HostObject, f: u32, event: &JsValue) -> u32; - /// 0, or 1 when it failed. `pre` is what to deliver first (see `Pre`), or 0. - #[wasm_bindgen(method)] - fn action(this: &HostObject, f: u32, event: &JsValue, pre: u32) -> u32; - /// Start an effect; the host keeps its cleanup. 0, or 1 when it failed. `pre` as for - /// `action`. - #[wasm_bindgen(method)] - fn effect(this: &HostObject, f: u32, event: &JsValue, pre: u32) -> u32; - /// Deliver `pre` on its own. 0, or 1 when it failed. - #[wasm_bindgen(method)] - fn pre(this: &HostObject, pre: u32) -> u32; - /// The delay in ms, or -1 when it failed. - #[wasm_bindgen(method)] - fn delay(this: &HostObject, f: u32, event: &JsValue) -> f64; - /// 0, `CHANGED` when the value changed (it stays in JS), or 1 when it failed. What - /// the evaluation read arrives first (`JsMachine::report`) when it differs from the - /// previous evaluation. - #[wasm_bindgen(method)] - fn computed(this: &HostObject, id: u32) -> u32; - /// The index of the first candidate of guard list `list` that passes, -1 for none, - /// -2 when a guard failed. - #[wasm_bindgen(method)] - fn pick(this: &HostObject, list: u32, event: &JsValue) -> i32; - /// Run action list `list` in order. 0, or 1 when an action failed. `pre` as for - /// `action`. - #[wasm_bindgen(method)] - fn actions(this: &HostObject, list: u32, event: &JsValue, pre: u32) -> u32; - /// Run transition `id` whole, from `from` to `to` (`flags`: 1 stops the effects, 2 - /// starts the target's). 0 done, 1 failed before the switch, 2 failed after it, 3 - /// failed while starting the effects. `pre` as for `action`. - #[wasm_bindgen(method)] - fn transition( - this: &HostObject, - id: u32, - from: u32, - to: u32, - event: &JsValue, - flags: u32, - pre: u32, - ) -> u32; -} - -/// The host's `computed` status bit: the value changed. -const CHANGED: u32 = 2; - -struct JsHost { - link: Rc, - /// What the computed evaluation in progress read, when JS reported it. - report: RefCell>, - /// What rides on the next action or effect call, delivered first (one crossing - /// instead of two), or at `settle`: bit 0 stops the effects, the bits above hold a - /// state change (the state + 1; 0 for none). - pre: Cell, -} - -const STOP_EFFECTS: u32 = 1; - -impl JsHost { - /// What is held back, for a call that delivers it first. - fn take_pre(&self) -> u32 { - self.pre.replace(0) - } - - /// Deliver what is held back, now. - fn deliver(&self) { - let pre = self.take_pre(); - if pre != 0 && self.link.host.pre(pre) != OK { - self.link.fail(); - } - } -} - -/// Run `f` with the event's JS value (`undefined` for a boot or a data-reaction). -fn with_event(event: Option<&JsEvent>, f: impl FnOnce(&JsValue) -> R) -> R { - match event { - Some(e) => f(&e.value), - None => f(&JsValue::UNDEFINED), - } -} - -impl Host for JsHost { - fn guard(&self, id: u32, event: Option<&JsEvent>) -> bool { - self.deliver(); - match with_event(event, |e| self.link.host.guard(id, e)) { - 0 => false, - 1 => true, - _ => { - self.link.fail(); - false - } - } - } - - fn action(&self, id: u32, event: Option<&JsEvent>) { - let pre = self.take_pre(); - if with_event(event, |e| self.link.host.action(id, e, pre)) != OK { - self.link.fail(); - } - } - - fn effect(&self, id: u32, event: Option<&JsEvent>) { - let pre = self.take_pre(); - if with_event(event, |e| self.link.host.effect(id, e, pre)) != OK { - self.link.fail(); - } - } - - fn stop_effects(&self) { - // A state change held back belongs before this stop. - if self.pre.get() > STOP_EFFECTS { - self.deliver(); - } - self.pre.set(self.pre.get() | STOP_EFFECTS); - } - - fn delay(&self, id: u32, event: Option<&JsEvent>) -> u32 { - self.deliver(); - let ms = with_event(event, |e| self.link.host.delay(id, e)); - if ms.is_nan() || ms < 0.0 { - self.link.fail(); - return 0; - } - ms as u32 - } - - fn computed(&self, id: u32) -> Evaluation { - self.deliver(); - let status = self.link.host.computed(id); - if status & FAILED != 0 { - self.link.fail(); - } - Evaluation { - changed: status & CHANGED != 0, - reads: self.report.borrow_mut().take(), - } - } - - fn pick(&self, list: u32, event: Option<&JsEvent>) -> Option { - self.deliver(); - match with_event(event, |e| self.link.host.pick(list, e)) { - -2 => { - self.link.fail(); - None - } - picked => usize::try_from(picked).ok(), - } - } - - fn actions(&self, list: u32, event: Option<&JsEvent>) { - let pre = self.take_pre(); - if with_event(event, |e| self.link.host.actions(list, e, pre)) != OK { - self.link.fail(); - } - } - - fn notify(&self, state: DynState, changes: Changes) { - // Context writes are JS's to announce; a state change waits for the next call. - if changes.fields != 0 { - self.deliver(); - self.link.notify(state.0, changes); - return; - } - if self.pre.get() > STOP_EFFECTS { - self.deliver(); - } - self.pre.set(self.pre.get() | ((state.0 + 1) << 1)); - } - - fn settle(&self) { - self.deliver(); - } - - fn transition( - &self, - id: u32, - from: DynState, - to: DynState, - event: Option<&JsEvent>, - stop: bool, - start: bool, - ) -> HostTransition { - let pre = self.take_pre(); - let flags = u32::from(stop) | (u32::from(start) << 1); - let outcome = with_event(event, |e| { - self.link.host.transition(id, from.0, to.0, e, flags, pre) - }); - if outcome != OK { - self.link.fail(); - } - match outcome { - 0 => HostTransition::Done, - 1 => HostTransition::FailedBefore, - 2 => HostTransition::FailedAfter, - _ => HostTransition::FailedInEffects, - } - } -} - -// --------------------------------------------------------------------------------- -// The JS classes. - -/// A compiled TS config, shared by every machine built from it. -#[wasm_bindgen] -pub struct JsConfig { - config: Config, -} - -#[wasm_bindgen] -impl JsConfig { - #[wasm_bindgen(constructor)] - pub fn new(spec: JsValue) -> Result { - let spec: Spec = serde_wasm_bindgen::from_value(spec) - .map_err(|e| JsError::new(&format!("[machine] bad config: {e}")))?; - Ok(JsConfig { - config: build(&spec), - }) - } -} - -/// One TS-authored machine running on the Rust engine. Calls return a status: 0, 1 when -/// user code failed (the JS side holds the error), 3 when the engine failed -/// (`takeFailure`). -#[wasm_bindgen] -pub struct JsMachine { - bridge: Bridge, - host: Rc, -} - -#[wasm_bindgen] -impl JsMachine { - /// `host` is the JS host object; its functions run for the machine whose call is in - /// progress. - #[wasm_bindgen(constructor)] - pub fn new(config: &JsConfig, host: HostObject) -> JsMachine { - let machine = Machine::new(&config.config); - let link = Rc::new(Link::new(host, machine.halt_flag())); - let bridge = Bridge::new(machine); - bridge.connect(link.clone()); - let host = Rc::new(JsHost { - link, - report: RefCell::new(None), - pre: Cell::new(0), - }); - bridge.machine().set_host(host.clone()); - JsMachine { bridge, host } - } - - pub fn send(&self, kind: u32, event: JsValue) -> u32 { - self.bridge.call(|m| m.send(JsEvent { kind, value: event })) - } - - pub fn start(&self) -> u32 { - self.bridge.start() - } - - pub fn stop(&self) -> u32 { - self.bridge.stop() - } - - pub fn running(&self) -> bool { - self.bridge.machine().is_running() - } - - pub fn state(&self) -> u32 { - self.bridge.machine().state().0 - } - - /// JS changed context fields (`lo`/`hi`: the low and high 32 bits of the mask), and - /// the machine has watchers: they may run. - #[wasm_bindgen(js_name = markChanged)] - pub fn mark_changed(&self, lo: u32, hi: u32) -> u32 { - self.bridge.call(|m| { - m.mark_changed((u64::from(hi) << 32) | u64::from(lo)); - }) - } - - /// JS changed context fields, and the machine has computed values but no watchers: - /// only stamp them. No code runs, so there is no status. - pub fn stamp(&self, lo: u32, hi: u32) { - self.bridge - .machine() - .mark_changed((u64::from(hi) << 32) | u64::from(lo)); - } - - /// Bring computed `id` up to date, evaluating what changed. JS holds the values. - pub fn computed(&self, id: u32) -> u32 { - self.bridge.call(|m| { - m.computed_version(id as usize); - }) - } - - /// What the computed evaluation in progress read, when it differs from the previous - /// evaluation (`lo`/`hi`: the fields; `state`; the other computed values). - pub fn report(&self, lo: u32, hi: u32, state: bool, computed: Option>) { - *self.host.report.borrow_mut() = Some(Reads { - fields: (u64::from(hi) << 32) | u64::from(lo), - state, - computed: computed - .into_iter() - .flatten() - .map(|id| id as usize) - .collect(), - }); - } - - /// The host clock: timer `id` (from `startTimer`) came due. - pub fn fire(&self, id: u32) -> u32 { - self.bridge.fire(id) - } - - /// The engine's failure behind status 3. - #[wasm_bindgen(js_name = takeFailure)] - pub fn take_failure(&self) -> Option { - self.bridge.take_failure() - } -} diff --git a/package.json b/package.json index b101078..d835914 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,13 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "test:rust": "cargo test --workspace", "benchmark": "pnpm -C benchmark benchmark", "benchmark:demo": "pnpm -C benchmark/demo dev", "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", - "build:wasm": "./scripts/build-wasm.sh", "typecheck": "tsc -b tsconfig.build.json", "lint": "oxlint .", - "lint:rust": "cargo clippy --workspace --all-targets -- -D warnings && cargo fmt --all --check", "format": "oxfmt .", "format:check": "oxfmt --check .", "knip": "knip", diff --git a/packages/core/README.md b/packages/core/README.md index 05416d1..bca25cd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,10 +4,8 @@ A tiny, **renderer-agnostic state-machine engine** for building UI component logic once and running it anywhere. It owns _behavior_, states, transitions, side-effects, derived state, and knows nothing about the render environment. -The engine is Rust, compiled to WebAssembly and inlined in the package: it starts -synchronously, with no `await init()`, in any JS runtime with WebAssembly (browsers, -Node, Bun, Deno). React Native's Hermes has none, so it does not run there yet. You -write machines in TypeScript, as below, or [in Rust](#machines-written-in-rust). +It's pure JavaScript: it runs in any JS runtime (browser, Node, the React +Native JS thread), but not in native platform code (e.g. Swift/Kotlin). ```ts import { machine, act } from '@dunky.dev/state-machine' @@ -114,9 +112,9 @@ monitoring walls, game HUDs). Context is one plain object mutated in place behin a value-deduping bus, so a transition allocates nothing and an irrelevant write wakes no observers. -In practice that's **~6× the event throughput** of the alternatives (~14× once -thousands of observers watch one machine), flat memory as context grows wide, and -surgical re-renders that wake only the rows that actually changed. +In practice that's **up to ~7× the event throughput** of the alternatives, flat +memory as context grows wide, and surgical re-renders that wake only the rows +that actually changed. **[See the benchmark →](../../benchmark/README.md)** for what's measured, how, and the full per-scenario tables vs. XState and Zag. @@ -916,52 +914,6 @@ const off = tooltipStore.subscribe(s => m.send({ type: 'activeChanged', openId: --- -## Machines written in Rust - -A machine can also be written in Rust, with the [`dunky-core`](../../crates/core) -crate, and exported to JS with one macro from [`dunky-wasm`](../../crates/wasm): - -```rust -dunky_wasm::export_machine! { - pub struct PaletteMachine(palette::Palette); - new(commands: JsValue) { - // A Result body throws its error from the JS constructor. - dunky_wasm::from_js(commands).map(|c| Machine::with_context(&CONFIG.with(Clone::clone), palette::context(c))) - } - computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } - // Served too, but left out of the machine's TS types. - internal { palette::RESULT_INDICES => Vec } -} -``` - -Build it with `wasm-bindgen`, add its TS types (natively, `PaletteMachine::typescript` -generates them from the Rust types, for the `.d.ts`), then wrap an instance with -`fromWasm`. The result is a `Machine` like any other: the connector, `useMachine` and -every target consume it unchanged, with the same notifications as a TS machine. - -```ts -import { fromWasm } from '@dunky.dev/state-machine' -import init, { PaletteMachine } from './pkg/machines.js' // your wasm-bindgen output - -await init() -// The types come from the class: generated from the Rust types, nothing to write. -const palette = fromWasm( - new PaletteMachine(commands), - // Optional: rebuild a computed value from another one, e.g. indices that crossed - // the boundary back onto your own objects. Runs only when the value changed. - { - computed: { - results: { from: 'resultIndices', map: raw => Array.from(raw as number[], i => commands[i]) }, - }, - }, -) -``` - -`context` is a plain JS mirror of the Rust context, with a stable identity; each change -re-reads only the fields it touched. - ---- - ## Putting it together ```ts diff --git a/packages/core/SPEC.md b/packages/core/SPEC.md index 3fdf5d2..e978577 100644 --- a/packages/core/SPEC.md +++ b/packages/core/SPEC.md @@ -16,21 +16,19 @@ anywhere. The map below is the whole engine — every feature and how they connect. ``` - config - the whole behavior, declared once a machine written in Rust - | (dunky-core), exported to - | initial state + seed context its own wasm module - | states, each with: on, entry, exit, effects, after, tags | - | any-state handlers: on | - | derived data: computed | - | data-reactions: watch | - | reusable + named: guards, actions, effects, delays | - | guards compose (and / or / not) | - | actions set context or run one-of | - | | - | build | wrap - v | - machine - a live service (current state, context, computed) <-----+ - | both kinds run on the Rust engine + config - the whole behavior, declared once + | + | initial state + seed context + | states, each with: on, entry, exit, effects, after, tags + | any-state handlers: on + | derived data: computed + | data-reactions: watch + | reusable + named: guards, actions, effects, delays + | guards compose (and / or / not); actions set context or run one-of + | + | build + v + machine - a live service (current state, context, computed) | | lifecycle: [ stopped ] --start--> [ running ] --stop--> | @@ -72,6 +70,11 @@ connect. +-- createStore .. state shared between machine instances ``` +**Two engines.** This contract has two implementations: this package +(TypeScript) and [`crates/core`](../../crates/core) (Rust). A behavior +change here lands in both, with their tests, in the same PR (see +[AGENTS.md](../../AGENTS.md#two-engines-one-spec)). + ## Intent Author a state **once**, as a plain state @@ -85,34 +88,18 @@ place rather than rebuilt on every change; there are no signals and no immutable snapshots. Performance is a constraint of the spec, not an afterthought (see [Performance guarantees](#performance-guarantees)). -The engine is **Rust**, compiled to WebAssembly (`crates/core`). A machine is -written in TypeScript (a config) or in Rust (`dunky-core`); both run on that -one engine, honor this spec, and give the same service, so a target cannot -tell them apart. For a TS machine, Rust runs the graph and calls back into -the user code, and the context stays a plain JS object. The protocol between -the engine and this package is -[`crates/wasm/SPEC.md`](../../crates/wasm/SPEC.md)'s; authoring in Rust is -[`crates/core/SPEC.md`](../../crates/core/SPEC.md)'s. - ## Scope & boundaries **In scope.** The state graph and its runtime; context and derived (computed) data; guards, actions, effects, timed (`after`) transitions, and data-reactions (`watch`); the observation surface; the view boundary -(the connector and its reactions); multi-machine composition; a -cross-instance reactive cell; and the service over a machine written in -Rust. - -**Runtime.** The package needs WebAssembly (browsers, Node, Bun, Deno). The -engine is inlined and starts synchronously the first time a machine is -built — importing the package costs nothing, and there is no asynchronous -initialization step. React Native's Hermes has no WebAssembly, so the -package does not run there yet. +(the connector and its reactions); multi-machine composition; and a +cross-instance reactive cell. **Out of scope — invariants, not preferences.** -- **No substrate.** Core is TypeScript over the Rust engine. No web, native, - or terminal API; no DOM, `window`, `document`, or platform globals. Reaching for one +- **No substrate.** Core is pure TypeScript. No web, native, or terminal + API; no DOM, `window`, `document`, or platform globals. Reaching for one means the code belongs in a target, not here. - **The machine never sees props.** A machine is pure behavior. It does not read the consumer's props; props enter only at the edge (context @@ -138,11 +125,6 @@ and bridged to a view by a **connector**. changes; and reusable **named** guards, actions, effects, and delays that transitions reference by name. - Building a config yields a **service** that is live but stopped. -- Wrapping a machine written in Rust yields the same kind of service — see - [Machines written in Rust](#machines-written-in-rust). -- Anything that builds a service (a target's lifecycle hook) accepts a - **source**: a config, which it builds into a stopped service, or a ready - service, which it uses as is. - A **connector** turns the service's current state into the surface a view renders and fires the consumer's callbacks — without the machine ever knowing those callbacks exist. @@ -211,9 +193,6 @@ method names it exposes. transitions exist only while running. - Lifecycle transitions are observable, so a bridge can wire and tear down external subscriptions in step with start and stop. -- A service lives as long as JS references it — a consumer, a dispatch - handle an effect kept, or a pending timer, which holds its service like a - JS timer holds `this`. The engine never keeps a service alive on its own. ### Run-to-completion @@ -224,7 +203,7 @@ method names it exposes. - Deferred work (a watcher's actions) shares the same queue and ordering. - A runaway feedback loop — e.g. a watcher that writes the field it watches, or actions that dispatch in a cycle — is detected and aborted - with a diagnostic, rather than hanging. + during development with a diagnostic, rather than hanging. ### Transition resolution @@ -293,8 +272,6 @@ The order of operations on a matched transition is fixed and observable: always see live values. - Updating context is a **shallow merge** that notifies observers **only if** at least one field actually changed; a no-op update is silent. -- The update is the only write the engine observes: mutating the object - directly notifies nobody, runs no watcher, and refreshes no derived value. - The common case — setting context — has a write-sugar that composes several patches in order, each an object or a function of the current params, with later function-patches seeing earlier writes. @@ -362,27 +339,6 @@ The order of operations on a matched transition is fixed and observable: select a single field, the current state, or a composite over anything. - Neither form fires on subscribe; both hand back a way to detach. -### Machines written in Rust - -A machine written in Rust, in its own wasm module, is wrapped into a -service. The wrapped service honors this whole contract, and adds: - -- **The same notifications.** Observers are notified once per change, in - order, exactly as for the same machine written in TypeScript. -- **A context mirror.** The context is one plain object with a stable - identity, updated in place: each change re-reads only the fields it - touched. It is read-only from JS; the machine's own actions write it. -- **Derived values served by Rust.** A derived value keeps its identity - until it changes. A **mapping** may rebuild it on the JS side from a Rust - value — its own or another derived value — and runs only when that value - changed; for example, indices that crossed the boundary map back onto the - host's own objects, so rich values never cross. -- **Events by type.** An event carries its payload by its fields; a - malformed payload is an error at dispatch. An event type the machine does - not know is ignored, like an unhandled event. -- **One wrapper.** A machine instance is wrapped once; wrapping it again is - an error. - ### Development vs. production - Referencing a name (guard, action, effect, or delay) with no matching @@ -495,6 +451,3 @@ chaining array methods, or allocating closures inside loops. ignored, never applied. - Referencing a missing named implementation is a hard error in development and a degraded no-op in production. -- An event type the machine does not know is ignored, for both kinds of - machine. -- A machine written in Rust is wrapped once; a second wrap is an error. diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts index 435c2fc..234d70b 100644 --- a/packages/core/src/actions.ts +++ b/packages/core/src/actions.ts @@ -1,4 +1,6 @@ -import type { Action, ActionArg, ActionParams, OneOf, OneOfBranch } from './types' +import { isDev } from './constants' +import { makeGuardParams, resolveGuard } from './guards' +import type { Action, ActionArg, Actions, ActionParams, Guard, OneOf, OneOfBranch } from './types' /** A context patch, or a function of the action params that returns one. */ export type Patch, Send = Event> = @@ -45,3 +47,55 @@ export function isOneOf( (action as { __oneOf?: boolean }).__oneOf === true ) } + +// `context` / `computed` are the live objects — their identity never changes, so no getter. +export interface ActionHost { + actions: Record> | undefined + guards: Record> | undefined + context: Context + computed: Computed + setContext: (patch: Partial) => void + send: (event: Event) => void +} + +export function runAction( + host: ActionHost, + action: ActionArg, + event: Event, +): void { + if (isOneOf(action)) { + const params = makeGuardParams(host.context, event, host.computed, host.guards) + for (const branch of action.branches) { + if (!branch.guard || resolveGuard(branch.guard, params, host.guards)) { + runActions(host, branch.actions, event) + return + } + } + return + } + const named = action as Exclude> + const fn = typeof named === 'function' ? named : host.actions?.[named] + if (!fn) { + const msg = `[machine] no action "${action as string}"` + if (isDev) throw new Error(msg) + console.warn(msg) + return + } + fn({ + context: host.context, + setContext: host.setContext, + event, + send: host.send, + computed: host.computed, + }) +} + +export function runActions( + host: ActionHost, + actions: Actions | undefined, + event: Event, +): void { + if (!actions) return + if (!Array.isArray(actions)) return runAction(host, actions, event) + for (const action of actions) runAction(host, action, event) +} diff --git a/packages/core/src/compile.ts b/packages/core/src/compile.ts deleted file mode 100644 index 72e93de..0000000 --- a/packages/core/src/compile.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Compiles a TS config for the Rust engine: the graph becomes numbers (states, event - * types, targets), the user code becomes tables the host callbacks index into. The - * spec shape mirrors `crates/wasm/src/runtime.rs`. - */ -import { isOneOf } from './actions' -import { isDev } from './constants' -import { JsConfig, startEngine } from './wasm' -import type { - ActionArg, - Actions, - Guard, - GuardArg, - TransitionConfig, - TransitionEntry, -} from './types' - -// The engine is generic over user code; inside the compiler everything is erased. -/* eslint-disable @typescript-eslint/no-explicit-any */ -type Fn = (params: any) => any -type AnyConfig = TransitionConfig -type AnyGuard = GuardArg -type AnyAction = ActionArg -type AnyActions = Actions -type AnyEntry = TransitionEntry -/* eslint-enable @typescript-eslint/no-explicit-any */ - -export interface ActionSpec { - id?: number - /** A run of actions the host runs in one call: an index into `actionLists`. */ - list?: number - oneOf?: Array<{ guard?: number; actions: ActionSpec[] }> -} -interface CandidateSpec { - /** The host runs the transition whole when it leaves the state: an index into - * `transitionActions`. */ - id?: number - target?: number - guard?: number - actions: ActionSpec[] -} -interface EntrySpec { - kind: number - candidates: CandidateSpec[] - /** The host evaluates the guards in one call: an index into `guardLists`. */ - pick?: number -} - -/** A TS config compiled for the Rust engine. Cached per config object. */ -export interface Compiled { - rust: JsConfig - initial: number - stateNames: string[] - kinds: Map - tags: Array> - /** Context key → change-mask bit. Bit 63 is shared by every key past the 63rd. */ - fieldBits: Map - /** Whether the engine needs to hear about context writes (watchers, computed values). */ - tracksContext: boolean - /** Whether a context write can run code in the engine (watchers). */ - watches: boolean - computedIndex: Map - computedDefs: Fn[] - guards: Fn[] - /** Per pick list: each candidate's guard, -1 for none. */ - guardLists: number[][] - /** Per action list: the actions, in order. */ - actionLists: number[][] - /** What a transition run whole needs: per transition id, its actions; per state, its - * exit and entry actions and its effects. */ - transitionActions: ActionSpec[][] - exitActions: ActionSpec[][] - entryActions: ActionSpec[][] - stateEffects: number[][] - actions: Fn[] - effects: Fn[] - delays: Fn[] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - guardRegistry: Record> | undefined -} - -/** The change-mask bit of `key`, assigning the next one on first sight. */ -export function fieldBit(bits: Map, key: string): number { - let bit = bits.get(key) - if (bit === undefined) { - bit = Math.min(bits.size, 63) - bits.set(key, bit) - } - return bit -} - -// A missing named implementation fails when it runs: a throw in dev, a warning in prod. -function missing(kind: 'guard' | 'action' | 'effect' | 'delay', name: string): Fn { - return () => { - const msg = `[machine] no ${kind} "${name}"` - if (isDev) throw new Error(msg) - console.warn(msg) - return kind === 'guard' ? false : kind === 'delay' ? 0 : undefined - } -} - -// Each distinct function gets one id in its table. -function table(list: Fn[]): (fn: Fn) => number { - const ids = new Map() - return fn => { - let id = ids.get(fn) - if (id === undefined) { - id = list.push(fn) - 1 - ids.set(fn, id) - } - return id - } -} - -const cache = new WeakMap() - -export function compile(config: AnyConfig): Compiled { - const cached = cache.get(config) - if (cached) return cached - startEngine() - - const impl = config.implementations ?? {} - const stateNames = Object.keys(config.states) - const stateIndex = new Map(stateNames.map((name, i) => [name, i])) - const indexOf = (name: string): number => { - const i = stateIndex.get(name) - if (i === undefined) throw new Error(`[machine] no state "${name}"`) - return i - } - const kinds = new Map() - const kindOf = (type: string): number => { - let kind = kinds.get(type) - if (kind === undefined) { - kind = kinds.size - kinds.set(type, kind) - } - return kind - } - - const guards: Fn[] = [] - const actions: Fn[] = [] - const effects: Fn[] = [] - const delays: Fn[] = [] - const guardId = table(guards) - const actionId = table(actions) - const effectId = table(effects) - const delayId = table(delays) - - const guardRef = (g: AnyGuard): number => - guardId(typeof g === 'function' ? g : (impl.guards?.[g] ?? missing('guard', g))) - const actionRef = (a: AnyAction): ActionSpec => - isOneOf(a) - ? { - oneOf: a.branches.map(b => ({ - guard: b.guard === undefined ? undefined : guardRef(b.guard), - actions: actionList(b.actions), - })), - } - : { id: actionId(typeof a === 'function' ? a : (impl.actions?.[a] ?? missing('action', a))) } - // One host call per run of plain actions, instead of one per action. - const guardLists: number[][] = [] - const actionLists: number[][] = [] - const actionList = (list: AnyActions | undefined): ActionSpec[] => { - if (list === undefined) return [] - const specs: ActionSpec[] = [] - let run: number[] = [] - const flush = () => { - if (run.length === 1) specs.push({ id: run[0] }) - else if (run.length > 1) specs.push({ list: actionLists.push(run) - 1 }) - run = [] - } - for (const a of Array.isArray(list) ? list : [list]) { - const spec = actionRef(a) - if (spec.id === undefined) { - flush() - specs.push(spec) - } else run.push(spec.id) - } - flush() - return specs - } - // One host call per guard walk, when two or more candidates have a guard. - // A leaving transition runs whole in one host call when nothing JS runs can read the - // engine's state mid-transition (no computed values) and the target has no named delay. - const runsWhole = Object.keys(config.computed ?? {}).length === 0 - const namedDelay = stateNames.map(name => - Object.keys(config.states[name]!.after ?? {}).some(key => Number.isNaN(Number(key))), - ) - const transitionActions: ActionSpec[][] = [] - const entrySpec = (entry: AnyEntry): { candidates: CandidateSpec[]; pick?: number } => { - const candidates: CandidateSpec[] = (Array.isArray(entry) ? entry : [entry]).map(el => { - if (typeof el === 'function') return { actions: actionList(el) } - const target = el.target === undefined ? undefined : indexOf(el.target) - const actions = actionList(el.actions) - const whole = runsWhole && target !== undefined && !namedDelay[target] - return { - id: whole ? transitionActions.push(actions) - 1 : undefined, - target, - guard: el.guard === undefined ? undefined : guardRef(el.guard), - actions, - } - }) - const guarded = candidates.filter(c => c.guard !== undefined).length - if (guarded < 2) return { candidates } - return { candidates, pick: guardLists.push(candidates.map(c => c.guard ?? -1)) - 1 } - } - const onTable = (on: object | undefined): EntrySpec[] => - Object.entries((on ?? {}) as Record) - .filter(([, entry]) => entry !== undefined) - .map(([type, entry]) => ({ kind: kindOf(type), ...entrySpec(entry) })) - - const states = stateNames.map(name => { - const node = config.states[name]! - return { - on: onTable(node.on), - entry: actionList(node.entry), - exit: actionList(node.exit), - effects: (node.effects ?? []).map(e => - effectId(typeof e === 'function' ? e : (impl.effects?.[e] ?? missing('effect', e))), - ), - after: Object.entries((node.after ?? {}) as Record) - .filter(([, entry]) => entry !== undefined) - .map(([key, entry]) => { - const ms = Number(key) - const delay = Number.isNaN(ms) - ? { id: delayId(impl.delays?.[key] ?? missing('delay', key)) } - : { ms } - return { delay, ...entrySpec(entry) } - }), - } - }) - - const fieldBits = new Map() - for (const key of Object.keys(config.context)) fieldBit(fieldBits, key) - const computedNames = Object.keys(config.computed ?? {}) - const computedIndex = new Map(computedNames.map((name, i) => [name, i])) - // Watch keys are probed on computed first (fixed at build), then on context fields, - // which may appear later. - const watch = Object.entries((config.watch ?? {}) as Record) - .filter(([, list]) => list !== undefined) - .map(([key, list]) => { - const computed = computedIndex.get(key) - return computed === undefined - ? { field: fieldBit(fieldBits, key), actions: actionList(list) } - : { computed, actions: actionList(list) } - }) - - const spec = { - initial: indexOf(config.initial), - kinds: 0, - states, - onAny: onTable(config.on), - computed: computedNames.length, - watch, - } - spec.kinds = kinds.size - - const compiled: Compiled = { - rust: new JsConfig(spec), - initial: spec.initial, - stateNames, - kinds, - tags: stateNames.map(name => new Set(config.states[name]!.tags ?? [])), - fieldBits, - tracksContext: watch.length > 0 || computedNames.length > 0, - watches: watch.length > 0, - computedIndex, - computedDefs: computedNames.map(name => (config.computed as Record)[name]!), - guards, - guardLists, - actionLists, - transitionActions, - exitActions: states.map(state => state.exit), - entryActions: states.map(state => state.entry), - stateEffects: states.map(state => state.effects), - actions, - effects, - delays, - guardRegistry: impl.guards, - } - cache.set(config, compiled) - return compiled -} diff --git a/packages/core/src/computed.ts b/packages/core/src/computed.ts new file mode 100644 index 0000000..c23fc8c --- /dev/null +++ b/packages/core/src/computed.ts @@ -0,0 +1,120 @@ +import type { ComputedDefs } from './types' + +// `context` / `computed` are the live objects (stable identity); only state is read through a fn. +export interface ComputedHost { + context: Context + computed: Computed + state: () => State +} + +/** + * Define computed getters on `target` with read-key tracking: each def records which + * context/computed keys it read and recomputes only when one of those inputs changed. + * Defined onto the SAME object the machine exposes as `this.computed` so computed→computed + * chains resolve in place. + */ +export function defineComputed( + target: Computed, + defs: ComputedDefs, + host: ComputedHost, +): void { + // Dep keys are runtime strings, so all dep reads are string-indexed — widen once here + // instead of casting at every read site. The proxy target is inert (traps never touch it). + const context = host.context as Record + const computed = host.computed as Record + const proxyTarget: Record = {} + + for (const key in defs) { + const k = key as keyof Computed + const def = defs[k] + let computedOnce = false + let cachedValue: Computed[keyof Computed] + let readState = false + let stateSnapshot: State | undefined + + // Parallel dep-key/dep-value buffers, reused across recomputes — a recompute + // allocates nothing. Values are captured AT read time inside the tracking + // proxies, so no post-pass re-reads (and re-validates) what was just computed. + const ctxDeps: string[] = [] + const ctxVals: unknown[] = [] + const computedDeps: string[] = [] + const computedVals: unknown[] = [] + + // True during recompute so proxy reads record deps and `params.state` records + // a state dependency. Deps are few, so the includes() dedup beats a Set. + let tracking = false + const trackedCtx = new Proxy(proxyTarget, { + get: (_t, p: string) => { + const value = context[p] + if (tracking && !ctxDeps.includes(p)) { + ctxDeps.push(p) + ctxVals.push(value) + } + return value + }, + }) as Context + + const trackedComputed = new Proxy(proxyTarget, { + get: (_t, p: string) => { + const value = computed[p] + if (tracking && !computedDeps.includes(p)) { + computedDeps.push(p) + computedVals.push(value) + } + return value + }, + }) as Computed + + // The def params never change shape — build them once, not per recompute. + const params = { + context: trackedCtx, + computed: trackedComputed, + get state() { + if (tracking) readState = true + return host.state() + }, + } + + const stale = (): boolean => { + if (readState && stateSnapshot !== host.state()) return true + + let i = 0 + while (i < ctxDeps.length) { + if (!Object.is(ctxVals[i], context[ctxDeps[i]!])) return true + i++ + } + + // Reading a computed dep resolves ITS staleness first — transitive changes surface here. + i = 0 + while (i < computedDeps.length) { + if (!Object.is(computedVals[i], computed[computedDeps[i]!])) return true + i++ + } + return false + } + + Object.defineProperty(target, k, { + enumerable: true, + get: () => { + if (computedOnce && !stale()) return cachedValue + ctxDeps.length = 0 + ctxVals.length = 0 + computedDeps.length = 0 + computedVals.length = 0 + readState = false + tracking = true + let completed = false + try { + cachedValue = def(params) as Computed[keyof Computed] + completed = true + } finally { + tracking = false + // A throwing def leaves the buffers half-filled — force the next read to recompute. + computedOnce = completed + } + stateSnapshot = readState ? host.state() : undefined + return cachedValue + }, + }) + } +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index a853360..d559488 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -11,3 +11,12 @@ export const MACHINE_INIT = 'machine.init' as const * that resolves a name so the rule lives in one place. */ export const isDev: boolean = process.env.NODE_ENV !== 'production' + +/** + * Dev-only runaway guard for one queue flush. A single send legitimately chains + * a handful of queued events / deferred watcher runs; thousands means a + * feedback loop (e.g. a watcher whose action keeps changing the field it + * watches, or actions sending in a cycle). Far above any real chain, so a hit + * is always a bug. + */ +export const MAX_FLUSH = 10_000 diff --git a/packages/core/src/from-wasm.ts b/packages/core/src/from-wasm.ts deleted file mode 100644 index c85cc48..0000000 --- a/packages/core/src/from-wasm.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { callFor, HOST, MachineClass, type Engine, type Shape } from './machine' -import type { Machine } from './types' - -/** An instance of a class exported with `dunky_wasm::export_machine!`. */ -export interface WasmMachine extends Engine { - /** Connect to the host. Returns a status. */ - attach: (host: object) => number - field: (index: number) => unknown - computed: (id: number) => unknown - meta: () => WasmMachineMeta -} - -/** - * A Rust machine's types. `pnpm build:wasm` generates them from the machine's Rust types - * onto its exported class, as a `__types` member that exists only in the types. - */ -export interface MachineTypes { - state: string - context: object - event: { type: string } - computed: object -} - -/** The types generated onto an exported class; loose ones when it has none. */ -export type TypesOf = Handle extends { readonly __types?: infer Types extends MachineTypes } - ? Types - : MachineTypes - -/** The names a Rust machine type reports, in its numbering. */ -export interface WasmMachineMeta { - states: string[] - events: string[] - fields: string[] - computed: string[] - /** Per state: its tags. */ - tags: string[][] -} - -/** How to produce one computed value on the JS side. */ -export type ComputedMapping = - | ((raw: unknown) => Value) - | { - /** Read this Rust computed value instead (e.g. indices instead of objects). */ - from: string - map: (raw: unknown) => Value - } - -export interface FromWasmOptions { - /** - * Map a raw computed value — e.g. indices that crossed the boundary, back onto the - * host's own objects. Runs only when the value changed. - */ - computed?: { [K in keyof Computed]?: ComputedMapping } -} - -interface RustShape extends Shape { - fields: string[] - computedIndex: Map -} - -// The shape is per machine TYPE: read it once per exported class, not per instance. -const shapes = new WeakMap() - -function shapeOf(handle: WasmMachine): RustShape { - const type = Object.getPrototypeOf(handle) as object - let shape = shapes.get(type) - if (!shape) { - const meta = handle.meta() - shape = { - stateNames: meta.states, - tags: meta.tags.map(tags => new Set(tags)), - kinds: new Map(meta.events.map((name, i) => [name, i])), - fields: meta.fields, - computedIndex: new Map(meta.computed.map((name, i) => [name, i])), - } - shapes.set(type, shape) - } - return shape -} - -const UNREAD = {} - -/** A Rust machine: the context is a mirror of the Rust one, re-read field by field. */ -class RustMachine< - State extends string, - Context extends object, - Event extends { type: string }, - Computed, -> extends MachineClass { - fields: string[] - declare handle: WasmMachine - - constructor( - shape: RustShape, - handle: WasmMachine, - mappings: Record | undefined>, - ) { - const ctx: Record = {} - for (let i = 0; i < shape.fields.length; i++) ctx[shape.fields[i]!] = handle.field(i) - super(shape, ctx as Context, handle.state()) - this.fields = shape.fields - this.handle = handle - this.isRunning = handle.running() - for (const [name, own] of shape.computedIndex) { - const mapping = mappings[name] - const map = typeof mapping === 'function' ? mapping : mapping?.map - const source = typeof mapping === 'object' ? shape.computedIndex.get(mapping.from) : own - if (source === undefined) { - throw new Error(`[machine] no computed "${(mapping as { from: string }).from}"`) - } - // Rust serves the same JS value until it changes, so identity marks a change. - let raw: unknown = UNREAD - let value: unknown - Object.defineProperty(this.computed, name, { - enumerable: true, - get: map - ? () => { - const next = handle.computed(source) - if (next !== raw) { - raw = next - value = map(next) - } - return value - } - : () => handle.computed(source), - }) - } - const status = callFor(this, () => handle.attach(HOST)) - if (status !== 0) this.raise(status) - } - - override refresh(lo: number, hi: number): void { - const ctx = this.ctx as Record - for (let bits = lo; bits !== 0; bits &= bits - 1) { - const i = 31 - Math.clz32(bits & -bits) - ctx[this.fields[i]!] = this.handle.field(i) - } - for (let bits = hi; bits !== 0; bits &= bits - 1) { - const i = 63 - Math.clz32(bits & -bits) - ctx[this.fields[i]!] = this.handle.field(i) - } - } -} - -/** - * Wrap a Rust machine (an instance of a class exported with - * `dunky_wasm::export_machine!`) in the `Machine` interface, so the connector and - * every target consume it like a TS-authored one. Its types are the class's, generated - * from the machine's Rust types. - */ -export function fromWasm( - handle: Handle, - options?: FromWasmOptions['computed']>, -): Machine< - TypesOf['state'], - TypesOf['context'], - TypesOf['event'], - TypesOf['computed'] -> -/** For a class built without generated types: name them. */ -export function fromWasm< - State extends string, - Context extends object, - Event extends { type: string }, - Computed = Record, ->( - handle: WasmMachine, - options?: FromWasmOptions, -): Machine -export function fromWasm( - handle: WasmMachine, - options: FromWasmOptions> = {}, -): Machine> { - return new RustMachine>( - shapeOf(handle), - handle, - (options.computed ?? {}) as Record | undefined>, - ) -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d091e1..6be01f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { machine, toMachine, type MachineSource } from './machine' +export { machine } from './machine' export { MACHINE_INIT } from './constants' export { setup } from './setup' export { and, or, not } from './guards' @@ -7,15 +7,6 @@ export { connector } from './connector' export { makeReaction } from './reaction' export { compose, type Composition } from './compose' export { createStore, type Store, type Listener, type SetStateAction } from './store' -export { - fromWasm, - type ComputedMapping, - type FromWasmOptions, - type MachineTypes, - type TypesOf, - type WasmMachine, - type WasmMachineMeta, -} from './from-wasm' export type { Machine, diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 72ac6af..0d97796 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,771 +1,316 @@ -import { makeBroadcast, type Broadcast } from './broadcast' -import { compile, fieldBit, type ActionSpec, type Compiled } from './compile' -import { MACHINE_INIT } from './constants' +import { type ActionHost, runActions } from './actions' +import { makeBroadcast } from './broadcast' +import { defineComputed } from './computed' +import { isDev, MACHINE_INIT, MAX_FLUSH } from './constants' import { makeGuardParams } from './guards' +import { shouldPatch } from './patch' import { makeSelection } from './selection' -import { JsMachine } from './wasm' +import { lookupOn, resolve } from './transitions' import type { - ActionParams, - GuardParams, + Actions, Machine, Select, Selection, + StateNode, + Transition, TransitionConfig, } from './types' -/** - * What the facade drives: a machine on the Rust engine. `JsMachine` (a TS-authored - * machine) and every class exported with `dunky_wasm::export_machine!` (a Rust machine) - * implement it. Calls that run machine code return a status (see `OK`); the host - * functions run for the facade whose call is in progress (`calling`). - */ -export interface Engine { - send: (kind: number, event: unknown) => number - start: () => number - stop: () => number - running: () => boolean - state: () => number - /** The host clock: timer `id` came due. */ - fire: (id: number) => number - /** The engine's failure behind `ENGINE_FAILED`. */ - takeFailure: () => string | undefined -} - -// The statuses of an engine call (crates/wasm/src/bridge.rs). JS owns the errors: a -// host function catches what user code throws and keeps it on the machine. -const OK = 0 -const FAILED = 1 -const ENGINE_FAILED = 3 -/** The host's `computed` status bit: the value changed. */ -const CHANGED = 2 -const NONE = Symbol('none') - -// The facade whose engine call is in progress: every host function runs for it. Calls -// nest (user code calls another machine), so each call saves and restores it. Keeping it -// here means no reference to the facade crosses into wasm. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFacade = MachineClass -let calling: AnyFacade = null! - -/** The names the facade maps the engine's numbers onto. */ -export interface Shape { - stateNames: string[] - tags: Array> - kinds: Map +// Tags depend only on static config — derive once per states-map, share across instances. +const tagsCache = new WeakMap>>() +function tagsForStates( + states: Record, +): Record> { + let tags = tagsCache.get(states) as Record> | undefined + if (!tags) { + tags = {} as Record> + for (const name in states) tags[name as State] = new Set(states[name as State].tags ?? []) + tagsCache.set(states, tags) + } + return tags } // One shared instance — the boot event carries no payload, so nothing needs a fresh object. const INIT_EVENT = Object.freeze({ type: MACHINE_INIT }) -/** - * The `Machine` interface over the Rust engine. Rust owns the graph: the queue, - * transition resolution, entry/exit order, the effects lifecycle, `after` timers, - * watchers and computed bookkeeping. The facade maps names to numbers, keeps the - * context object and fans each change out to the subscribers. - */ -export class MachineClass< +class MachineClass< State extends string, Context extends object, Event extends { type: string }, Computed, -> implements Machine { - shape: Shape - handle!: Engine +> { + config: TransitionConfig ctx: Context - computed = {} as Computed - /** The current state's index, moved by every notify. */ - current: number - broadcast: Broadcast = makeBroadcast() + stateValue: State + tagsOf: Record> + broadcast = makeBroadcast() + // Run-to-completion queue. Events (objects) and deferred jobs (functions) both wait for + // the in-flight transition to finish before running. + queue: Array void)> = [] + flushing = false + running = false + // Bumped on every state ENTRY. An `after` timer captures the generation at schedule time; + // if the machine exits and re-enters the same state before the timer fires, the generation + // no longer matches and the stale timer is ignored. + entryCounter = 0 + stateCleanups: Array<() => void> = [] + watcherCleanups: Array<() => void> = [] // Lazily created — a machine with no connector pays nothing. startListeners: Set<() => void> | null = null stopListeners: Set<() => void> | null = null - selectFacade: Select | null = null - /** The first error user code threw in the current call. */ - error: unknown = NONE - /** Nesting of engine calls: JS code calls back in from inside one. */ - depth = 0 - /** Started and not stopped (mirrors the engine, so JS needs no call to ask). */ - isRunning = false + computed: Computed + actionHost: ActionHost - constructor(shape: Shape, context: Context, current: number) { - this.shape = shape - this.ctx = context - this.current = current - } - - /** A change named context fields `lo`/`hi` (the low and high 32 bits of the mask). */ - refresh(_lo: number, _hi: number): void {} + constructor(config: TransitionConfig) { + this.config = config + // Own copy from birth — identity never changes, writes mutate in place. + // Refs captured in effects/actions always see the live context. + this.ctx = { ...config.context } + this.stateValue = config.initial + this.tagsOf = tagsForStates(config.states) + + this.computed = {} as Computed + if (config.computed) { + defineComputed(this.computed, config.computed, { + context: this.ctx, + computed: this.computed, + state: () => this.stateValue, + }) + } - /** The machine is in `state` now: tell the subscribers. */ - enter(state: number): void { - this.current = state - this.broadcast.notify() + this.actionHost = { + actions: config.implementations?.actions, + guards: config.implementations?.guards, + context: this.ctx, + computed: this.computed, + setContext: this.setContext, + send: this.send, + } } - /** Keep the first error of the call; the status tells the engine to stop the step. */ - fail(error: unknown): number { - if (this.error === NONE) this.error = error - return FAILED - } - /** Throw what failed: the outermost call takes the error, a nested one leaves it. */ - raise(status: number): never { - if (status === ENGINE_FAILED) throw new Error(this.handle.takeFailure()) - const error = this.error - if (this.depth === 0) this.error = NONE - throw error + setContext = (patch: Partial): void => { + if (!shouldPatch(this.ctx, patch)) return + Object.assign(this.ctx, patch) // in place — this.ctx identity never changes + this.broadcast.notify() } - send = (event: Event): void => { - const kind = this.shape.kinds.get(event.type) - if (kind === undefined) return // handled nowhere: a no-op - const outer = calling - calling = this - this.depth++ - const status = this.handle.send(kind, event) - this.depth-- - calling = outer - if (status !== OK) this.raise(status) - } - fire(id: number): void { - const outer = calling - calling = this - this.depth++ - const status = this.handle.fire(id) - this.depth-- - calling = outer - if (status !== OK) this.raise(status) + this.enqueue(event) } get state(): State { - return this.shape.stateNames[this.current] as State + return this.stateValue } get context(): Context { return this.ctx } hasTag(tag: string): boolean { - return this.shape.tags[this.current]!.has(tag) + return this.tagsOf[this.stateValue].has(tag) } matches(name: State): boolean { - return this.shape.stateNames[this.current] === name + return this.stateValue === name } - start = (): void => { - if (this.isRunning) return - this.isRunning = true - const outer = calling - calling = this - this.depth++ - const status = this.handle.start() - this.depth-- - calling = outer - if (status !== OK) this.raise(status) - if (this.startListeners) for (const fn of this.startListeners) fn() - } - stop = (): void => { - if (!this.isRunning) return - this.isRunning = false - const outer = calling - calling = this - this.depth++ - const status = this.handle.stop() - this.depth-- - calling = outer - if (status !== OK) this.raise(status) - if (this.stopListeners) for (const fn of this.stopListeners) fn() - } - onStart = (fn: () => void): (() => void) => { - ;(this.startListeners ??= new Set()).add(fn) - if (this.isRunning) fn() // already running — fire immediately so late registrants don't miss it - return () => this.startListeners?.delete(fn) - } - onStop = (fn: () => void): (() => void) => { - ;(this.stopListeners ??= new Set()).add(fn) - return () => this.stopListeners?.delete(fn) + private setState(next: State): void { + if (next === this.stateValue) return + this.stateValue = next + this.broadcast.notify() } - subscribe = (listener: () => void): (() => void) => this.broadcast.add(listener) - - private makeSelection(selector: () => Value): Selection { - return makeSelection(selector, onWake => this.broadcast.add(onWake)) + private runActions(actions: Actions | undefined, event: Event): void { + runActions(this.actionHost, actions, event) } - // Built on first access, then reused — the facade is stateless, so one instance serves all reads. - get select(): Select { - if (this.selectFacade) return this.selectFacade - const sel = ((selector: () => Value) => this.makeSelection(selector)) as Select< - State, - Context, - Computed - > - sel.context = (key: K) => this.makeSelection(() => this.ctx[key]) - sel.computed = (key: K) => - this.makeSelection(() => this.computed[key]) - sel.state = () => this.makeSelection(() => this.state) - return (this.selectFacade = sel) - } -} - -// What the computed evaluation in progress read. Evaluations nest (a computed value -// reading another), so each one saves and restores the frame around it. -let readLo = 0 -let readHi = 0 -let readsState = false -let reads: number[] | null = null - -function markRead(bit: number): void { - if (bit < 32) readLo |= 1 << bit - else readHi |= 1 << (bit - 32) -} - -/** What JS keeps of one computed value of a TS machine. */ -interface CacheEntry { - value: unknown - /** No input changed since the value was evaluated or checked. */ - valid: boolean - /** What the last evaluation read: fields (the low and high 32 bits of the mask), the - * state, other computed values. */ - lo: number - hi: number - state: boolean - computed: number[] | null - /** The computed values that read this one. */ - readers: number[] | null - /** Rust has the reads (false until the first evaluation). */ - reported: boolean - /** The last clearing pass that reached this entry. */ - pass: number -} -function newEntry(): CacheEntry { - return { - value: undefined, - valid: false, - lo: 0, - hi: 0, - state: false, - computed: null, - readers: null, - reported: false, - pass: 0, + private applyTransition(t: Transition, event: Event): void { + const cur = this.stateValue + const next = t.target ?? cur + const changed = next !== cur + if (changed) { + if (this.running) this.stopEffects() + this.runActions(this.config.states[cur].exit, event) + } + this.runActions(t.actions, event) + if (changed) { + this.setState(next) + this.runActions(this.config.states[next].entry, event) + if (this.running) this.startEffects(next, event) + } } -} - -let clearPass = 0 - -// Walk past an entry that is already cleared: Rust may have checked its readers since -// (an input changed but kept its value), so they can be valid. -function clearEntry(cache: CacheEntry[], entry: CacheEntry, pass: number): void { - if (entry.pass === pass) return - entry.pass = pass - entry.valid = false - const readers = entry.readers - if (readers) for (let i = 0; i < readers.length; i++) clearEntry(cache, cache[readers[i]!]!, pass) -} - -function sameIds(a: number[] | null, b: number[] | null): boolean { - if (a === b) return true - if (!a || !b || a.length !== b.length) return false - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false - return true -} - -// The params of the dispatch in progress: its guards and actions share them. One slot -// for all machines, so no machine keeps a dispatch's params alive. -let paramsOf: object | null = null -let paramsEvent: unknown = null -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let actionParams: ActionParams | null = null -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let guardParams: GuardParams | null = null - -function reuse(machine: object, event: unknown): void { - paramsOf = machine - paramsEvent = event - actionParams = null - guardParams = null -} - -/** - * A TS-authored machine. Rust calls back (the host functions below) to run its user - * code; the context is a plain JS object, mutated in place, and JS reports each write's - * change mask so Rust can drive the watchers and computed staleness. - */ -class TsMachine< - State extends string, - Context extends object, - Event extends { type: string }, - Computed, -> extends MachineClass { - c: Compiled - declare handle: JsMachine - /** The cleanups of the effects started in the current state, in start order. */ - effectCleanups: Array<() => void> = [] - /** Per computed value, its cached value and inputs; null without computed values. */ - cache: CacheEntry[] | null - tracked: { context: Context; computed: Computed; readonly state: State } | null = null - - constructor(c: Compiled, context: Context) { - // Own copy from birth — identity never changes, writes mutate in place. - // Refs captured in effects/actions always see the live context. - super(c, { ...context }, c.initial) - this.c = c - this.cache = c.computedDefs.length === 0 ? null : c.computedDefs.map(newEntry) - for (const [name, id] of c.computedIndex) { - Object.defineProperty(this.computed, name, { enumerable: true, get: () => this.read(id) }) + // Re-entrant enqueues (send from an action, watcher mid-transition) wait for the current flush. + private enqueue(item: Event | (() => void)): void { + this.queue.push(item) + if (this.flushing) return + this.flushing = true + try { + this.flushQueue() + } finally { + this.flushing = false } - // Rust holds the facade only during a call: only JS references (a consumer, an - // effect's `send`, a pending timer) keep the machine alive, as with any JS object. - this.handle = new JsMachine(c.rust, HOST) } - - setContext = (patch: Partial): void => { - const ctx = this.ctx as Record - // Only watchers and computed values need the engine to hear about a write. - if (!this.c.tracksContext) { - let changed = false - for (const key in patch) { - const value = (patch as Record)[key] - if (Object.is(ctx[key], value)) continue - ctx[key] = value // in place — ctx identity never changes - changed = true + private flushQueue(): void { + let ticks = 0 + while (this.queue.length) { + if (isDev && ++ticks > MAX_FLUSH) { + throw new Error( + `[machine] one flush exceeded ${MAX_FLUSH} steps — feedback loop ` + + '(e.g. a watcher writing the field it watches, or actions sending in a cycle)', + ) } - if (changed) this.broadcast.notify() - return - } - let lo = 0 - let hi = 0 - for (const key in patch) { - const value = (patch as Record)[key] - if (Object.is(ctx[key], value)) continue - ctx[key] = value - const bit = fieldBit(this.c.fieldBits, key) - if (bit < 32) lo |= 1 << bit - else hi |= 1 << (bit - 32) + const item = this.queue.shift()! + if (typeof item === 'function') { + item() + continue + } + const t = resolve(lookupOn(this.config, this.stateValue, item.type), item, this.actionHost) + if (t) this.applyTransition(t, item) } - if ((lo | hi) === 0) return - const read = this.cache !== null && this.clear(lo, hi, false) - if (this.c.watches) { - const outer = calling - calling = this - this.depth++ - const status = this.handle.markChanged(lo >>> 0, hi >>> 0) - this.depth-- - calling = outer - if (status !== OK) this.raise(status) - } else if (read) { - // The engine's stamps only matter for fields a computed value read. - this.handle.stamp(lo >>> 0, hi >>> 0) + } + private resolveDelay(key: string, event: Event): number { + const asNum = Number(key) + if (!Number.isNaN(asNum)) return asNum + const fn = this.config.implementations?.delays?.[key] + if (!fn) { + const msg = `[machine] no delay "${key}"` + if (isDev) throw new Error(msg) + console.warn(msg) + return 0 } - this.broadcast.notify() + return fn(makeGuardParams(this.ctx, event, this.computed, this.config.implementations?.guards)) } - - override enter(state: number): void { - if (this.cache) this.clear(0, 0, true) - super.enter(state) + // Runs as a queued job so the timer joins the run-to-completion queue like any send. + // Stale when the machine stopped or the state was exited (and maybe re-entered) since scheduling. + private dispatchAfter(scheduledIn: State, key: string, event: Event, generation: number): void { + if (!this.running || this.entryCounter !== generation) return + const t = resolve(this.config.states[scheduledIn].after?.[key], event, this.actionHost) + if (t) this.applyTransition(t, event) } - /** Clear the cached values that read a field of `lo`/`hi` (or the state, with - * `state`), and the values that read a cleared one. True when one read them. */ - clear(lo: number, hi: number, state: boolean): boolean { - const cache = this.cache! - const pass = ++clearPass - let read = false - for (let i = 0; i < cache.length; i++) { - const entry = cache[i]! - if ((entry.lo & lo) !== 0 || (entry.hi & hi) !== 0 || (state && entry.state)) { - read = true - clearEntry(cache, entry, pass) + private startEffects(state: State, event: Event): void { + const generation = ++this.entryCounter + const after = this.config.states[state].after + if (after) { + for (const key in after) { + const ms = this.resolveDelay(key, event) + const id = setTimeout( + () => this.enqueue(() => this.dispatchAfter(state, key, event, generation)), + ms, + ) + this.stateCleanups.push(() => clearTimeout(id)) } } - return read - } - - /** Read computed `id`; throw if its definition failed during this read. */ - read(id: number): unknown { - // Every input of a TS machine changes through JS, so a valid entry is the value. - const entry = this.cache![id]! - if (entry.valid) return entry.value - const outer = calling - calling = this - this.depth++ - const status = this.handle.computed(id) - this.depth-- - calling = outer - if (status !== OK) this.raise(status) - entry.valid = true - return entry.value - } - - /** Run compiled action specs in order: callbacks, runs of callbacks, `oneOf`s. */ - runSpecs( - specs: ActionSpec[], - params: ActionParams, - event: unknown, - ): void { - const { actions } = this.c - for (let i = 0; i < specs.length; i++) { - const spec = specs[i]! - if (spec.id !== undefined) actions[spec.id]!(params) - else if (spec.list !== undefined) { - const list = this.c.actionLists[spec.list]! - for (let j = 0; j < list.length; j++) actions[list[j]!]!(params) - } else if (spec.oneOf) { - const guards = this.guardParams(event) - for (const branch of spec.oneOf) { - if (branch.guard === undefined || this.c.guards[branch.guard]!(guards)) { - this.runSpecs(branch.actions, params, event) - break - } - } + const effects = this.config.states[state].effects + if (!effects) return + for (const effect of effects) { + const fn = + typeof effect === 'function' ? effect : this.config.implementations?.effects?.[effect] + if (!fn) { + const msg = `[machine] no effect "${effect as string}"` + if (isDev) throw new Error(msg) + console.warn(msg) + continue } + const cleanup = fn({ + context: this.ctx, + setContext: this.setContext, + event, + send: this.send, + computed: this.computed, + }) + if (typeof cleanup === 'function') this.stateCleanups.push(cleanup) } } - - /** What the engine held back to ride on this call: bit 0 stops the effects, the bits - * above hold a state change (the state + 1). */ - prelude(pre: number): void { - if (pre & 1) this.stopEffects() - const state = (pre >>> 1) - 1 - if (state >= 0) this.enter(state) - } - /** Run every cleanup, in start order, even past one that throws; then throw the first. */ - stopEffects(): void { - const cleanups = this.effectCleanups - if (cleanups.length === 0) return - this.effectCleanups = [] - let failed = false - let first: unknown - for (let i = 0; i < cleanups.length; i++) { + private stopEffects(): void { + // A throwing cleanup must not leak the others (timers, subscriptions) or leave the + // list populated for a double run on the next stop. Finish the pass, rethrow after. + const cleanups = this.stateCleanups + let thrown: unknown + let didThrow = false + for (const cleanup of cleanups) { try { - cleanups[i]!() + cleanup() } catch (error) { - if (!failed) { - failed = true - first = error + if (!didThrow) { + didThrow = true + thrown = error } } } - if (failed) throw first + cleanups.length = 0 + if (didThrow) throw thrown } - actionParams(event: unknown): ActionParams { - const e = event ?? INIT_EVENT - if (paramsOf !== this || paramsEvent !== e) reuse(this, e) - return (actionParams ??= { - context: this.ctx, - setContext: this.setContext, - event: e, - send: this.send, - computed: this.computed, - }) as ActionParams + private startWatchers(): void { + const watch = this.config.watch + if (!watch) return + for (const key in watch) { + const actions = watch[key as keyof typeof watch] + if (!actions) continue + // Bind the source once: computed keys are fixed at construction, while ctx keys + // may appear later (optional fields patched in), so membership is probed on computed. + const source = (key in (this.computed as object) ? this.computed : this.ctx) as Record< + string, + unknown + > + // Defer: the selection fires inside notify() (mid-transition). Running actions immediately + // would be re-entrant. The `running` check at job time drops pending runs on stop(). + const off = this.makeSelection(() => source[key]).subscribe(() => { + this.enqueue(() => { + if (this.running) this.runActions(actions, INIT_EVENT as Event) + }) + }) + this.watcherCleanups.push(off) + } } - guardParams(event: unknown): GuardParams { - const e = event ?? INIT_EVENT - if (paramsOf !== this || paramsEvent !== e) reuse(this, e) - return (guardParams ??= makeGuardParams( - this.ctx, - e, - this.computed, - this.c.guardRegistry, - )) as GuardParams + private stopWatchers(): void { + for (const dispose of this.watcherCleanups) dispose() + this.watcherCleanups.length = 0 } - /** Run computed `id` for the engine, which owns its staleness. The value stays here; - * the status says whether it changed, and what it read crosses only when it differs - * from the previous evaluation. */ - evaluate(id: number): number { - const entry = this.cache![id]! - const lo = readLo - const hi = readHi - const state = readsState - const outer = reads - readLo = 0 - readHi = 0 - readsState = false - reads = null - entry.valid = false - try { - const value = this.c.computedDefs[id]!(this.trackedParams()) - const changed = !Object.is(entry.value, value) - entry.value = value - entry.valid = true - const sameComputed = sameIds(reads, entry.computed) - if ( - !entry.reported || - !sameComputed || - readLo !== entry.lo || - readHi !== entry.hi || - readsState !== entry.state - ) { - entry.reported = true - entry.lo = readLo - entry.hi = readHi - entry.state = readsState - if (!sameComputed) { - entry.computed = reads - this.linkReaders() - } - this.handle.report( - readLo >>> 0, - readHi >>> 0, - readsState, - reads ? Uint32Array.from(reads) : undefined, - ) - } - return changed ? CHANGED : OK - } finally { - readLo = lo - readHi = hi - readsState = state - reads = outer - } + start = (): void => { + if (this.running) return + this.running = true + this.startWatchers() + // Boot the CURRENT state's effects — stop() doesn't reset stateValue, so a + // restart (e.g. StrictMode mount→unmount→mount) may be in any state. + this.startEffects(this.stateValue, INIT_EVENT as Event) + if (this.startListeners) for (const fn of this.startListeners) fn() } - /** Rebuild who reads whom, after an evaluation read other computed values. */ - linkReaders(): void { - const cache = this.cache! - for (let i = 0; i < cache.length; i++) cache[i]!.readers = null - for (let i = 0; i < cache.length; i++) { - const read = cache[i]!.computed - if (read) for (let j = 0; j < read.length; j++) (cache[read[j]!]!.readers ??= []).push(i) - } + stop = (): void => { + if (!this.running) return + this.running = false + this.stopEffects() + this.stopWatchers() + if (this.stopListeners) for (const fn of this.stopListeners) fn() } - trackedParams(): { context: Context; computed: Computed; readonly state: State } { - if (this.tracked) return this.tracked - const { c, handle } = this - const machine = this - const context = new Proxy(this.ctx, { - get(target, key) { - if (typeof key === 'string') markRead(fieldBit(c.fieldBits, key)) - return Reflect.get(target, key) - }, - }) - const computed = new Proxy(this.computed as object, { - get(target, key) { - const id = typeof key === 'string' ? c.computedIndex.get(key) : undefined - if (id === undefined) return Reflect.get(target, key) - reads ??= [] - if (!reads.includes(id)) reads.push(id) - return machine.read(id) - }, - }) as Computed - return (this.tracked = { - context, - computed, - get state() { - readsState = true - // Read live: a computed value can run mid-transition, before the notify that - // moves `current`. - return c.stateNames[handle.state()] as State - }, - }) + onStart = (fn: () => void): (() => void) => { + ;(this.startListeners ??= new Set()).add(fn) + if (this.running) fn() // already running — fire immediately so late registrants don't miss it + return () => this.startListeners?.delete(fn) + } + onStop = (fn: () => void): (() => void) => { + ;(this.stopListeners ??= new Set()).add(fn) + return () => this.stopListeners?.delete(fn) } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type TsFacade = TsMachine -type Timeout = ReturnType - -/** The JS functions the engine calls back into (see crates/wasm/src/bridge.rs), for the - * facade whose call is in progress (`calling`). They never throw: each catches what user - * code throws, keeps it on the machine, and returns a failure status. */ -interface Host { - /** 0 false, 1 true, 2 failed. */ - guard: (fn: number, event: unknown) => number - /** `pre`: what rides on this call, delivered first (see `prelude`), or 0. */ - action: (fn: number, event: unknown, pre: number) => number - /** Start an effect; its cleanup waits in `effectCleanups`. */ - effect: (fn: number, event: unknown, pre: number) => number - /** Deliver `pre` on its own. */ - pre: (pre: number) => number - /** The delay in ms, -1 failed. */ - delay: (fn: number, event: unknown) => number - /** 0, `CHANGED` when the value changed (it stays in JS), 1 failed. */ - computed: (id: number) => number - /** The first candidate of guard list `list` that passes, -1 for none, -2 failed. */ - pick: (list: number, event: unknown) => number - actions: (list: number, event: unknown, pre: number) => number - /** Run transition `id` whole (`flags`: 1 stops the effects, 2 starts the target's). - * 0 done, 1 failed before the switch, 2 after it, 3 while starting the effects. */ - transition: ( - id: number, - from: number, - to: number, - event: unknown, - flags: number, - pre: number, - ) => number - notify: (state: number, lo: number, hi: number) => number - startTimer: (id: number, ms: number) => Timeout - cancelTimer: (timeout: Timeout) => void -} - -/** Run `call` as an engine call of `facade`, so the host functions run for it. */ -export function callFor(facade: AnyFacade, call: () => number): number { - const outer = calling - calling = facade - facade.depth++ - const status = call() - facade.depth-- - calling = outer - return status -} - -/** The host functions Rust calls; user code by its index in the compiled tables. */ -export const HOST: Host = { - guard: (fn, event) => { - const m = calling as TsFacade - try { - return m.c.guards[fn]!(m.guardParams(event)) ? 1 : 0 - } catch (error) { - m.fail(error) - return 2 - } - }, - action: (fn, event, pre) => { - const m = calling as TsFacade - try { - if (pre) m.prelude(pre) - m.c.actions[fn]!(m.actionParams(event)) - return OK - } catch (error) { - return m.fail(error) - } - }, - effect: (fn, event, pre) => { - const m = calling as TsFacade - try { - if (pre) m.prelude(pre) - const cleanup = m.c.effects[fn]!(m.actionParams(event)) - if (typeof cleanup === 'function') m.effectCleanups.push(cleanup) - return OK - } catch (error) { - return m.fail(error) - } - }, - pre: pre => { - const m = calling as TsFacade - try { - m.prelude(pre) - return OK - } catch (error) { - return m.fail(error) - } - }, - delay: (fn, event) => { - const m = calling as TsFacade - try { - const ms = m.c.delays[fn]!(m.guardParams(event)) - return ms > 0 ? ms : 0 // like setTimeout: negative or NaN means now - } catch (error) { - m.fail(error) - return -1 - } - }, - computed: id => { - const m = calling as TsFacade - try { - return m.evaluate(id) - } catch (error) { - return m.fail(error) - } - }, - pick: (list, event) => { - const m = calling as TsFacade - try { - const candidates = m.c.guardLists[list]! - const params = m.guardParams(event) - for (let i = 0; i < candidates.length; i++) { - const guard = candidates[i]! - if (guard < 0 || m.c.guards[guard]!(params)) return i - } - return -1 - } catch (error) { - m.fail(error) - return -2 - } - }, - actions: (list, event, pre) => { - const m = calling as TsFacade - try { - if (pre) m.prelude(pre) - const actions = m.c.actionLists[list]! - const params = m.actionParams(event) - for (let i = 0; i < actions.length; i++) m.c.actions[actions[i]!]!(params) - return OK - } catch (error) { - return m.fail(error) - } - }, - transition: (id, from, to, event, flags, pre) => { - const m = calling as TsFacade - let phase = 1 - try { - if (pre) m.prelude(pre) - if (flags & 1) m.stopEffects() - const params = m.actionParams(event) - m.runSpecs(m.c.exitActions[from]!, params, event) - m.runSpecs(m.c.transitionActions[id]!, params, event) - phase = 2 - m.enter(to) - m.runSpecs(m.c.entryActions[to]!, params, event) - // An action may have stopped the machine mid-transition. - if (flags & 2 && m.isRunning) { - phase = 3 - const effects = m.c.stateEffects[to]! - for (let i = 0; i < effects.length; i++) { - const cleanup = m.c.effects[effects[i]!]!(params) - if (typeof cleanup === 'function') m.effectCleanups.push(cleanup) - } - } - return OK - } catch (error) { - m.fail(error) - return phase - } - }, - notify: (state, lo, hi) => { - const m = calling - try { - if (lo | hi) { - m.current = state - m.refresh(lo, hi) - m.broadcast.notify() - } else m.enter(state) - return OK - } catch (error) { - return m.fail(error) - } - }, - // The pending timer holds the machine, like a JS timer holds `this`. - startTimer: (id, ms) => { - const m = calling - return setTimeout(() => m.fire(id), ms) - }, - cancelTimer: timeout => clearTimeout(timeout), -} -/** - * What a bridge can build a service from: a config (run by this engine) or a ready - * machine — e.g. a Rust machine wrapped with `fromWasm`. - */ -export type MachineSource< - State extends string, - Context extends object, - Event extends { type: string }, - Computed = Record, -> = TransitionConfig | Machine + subscribe = (listener: () => void): (() => void) => this.broadcast.add(listener) -/** Build a stopped service from a config; a ready machine is returned as is. */ -export function toMachine< - State extends string, - Context extends object, - Event extends { type: string }, - Computed = Record, ->( - source: MachineSource, -): Machine { - return typeof (source as Machine).send === 'function' - ? (source as Machine) - : machine(source as TransitionConfig) + private makeSelection(selector: () => Value): Selection { + return makeSelection(selector, onWake => this.broadcast.add(onWake)) + } + // Built on first access, then reused — the facade is stateless, so one instance serves all reads. + selectFacade: Select | null = null + get select(): Select { + if (this.selectFacade) return this.selectFacade + const sel = ((selector: () => Value) => this.makeSelection(selector)) as Select< + State, + Context, + Computed + > + sel.context = (key: K) => this.makeSelection(() => this.ctx[key]) + sel.computed = (key: K) => + this.makeSelection(() => this.computed[key]) + sel.state = () => this.makeSelection(() => this.stateValue) + return (this.selectFacade = sel) + } } export function machine< @@ -776,5 +321,5 @@ export function machine< >( config: TransitionConfig, ): Machine { - return new TsMachine(compile(config), config.context) + return new MachineClass(config) as unknown as Machine } diff --git a/packages/core/src/transitions.ts b/packages/core/src/transitions.ts new file mode 100644 index 0000000..7036724 --- /dev/null +++ b/packages/core/src/transitions.ts @@ -0,0 +1,57 @@ +import { makeGuardParams } from './guards' +import type { Guard, GuardParams, Transition, TransitionConfig, TransitionEntry } from './types' + +/** What a guard needs to run — the machine's action host satisfies it. */ +export interface GuardHost { + context: Context + computed: Computed + guards: Record> | undefined +} + +/** Look up the `on` entry for an event: current state first, falling back to `config.on`. */ +export function lookupOn< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +>( + config: TransitionConfig, + stateValue: State, + type: Event['type'], +): TransitionEntry | undefined { + const onState = config.states[stateValue].on as + | Record> + | undefined + const onAny = config.on as + | Record> + | undefined + return onState?.[type] ?? onAny?.[type] +} + +/** + * Return the first transition whose guard passes, across the three entry forms + * (object / bare fn / array). A bare fn is a guardless `{ actions: fn }`. + * Guard params are built once per resolve and only when a guard is met — the + * common guardless send allocates nothing here. + */ +export function resolve( + entry: TransitionEntry | undefined, + event: Event, + host: GuardHost, +): Transition | undefined { + if (entry === undefined) return undefined + if (!Array.isArray(entry)) { + if (typeof entry === 'function') return { actions: entry } + if (!entry.guard) return entry + const params = makeGuardParams(host.context, event, host.computed, host.guards) + return params.guard(entry.guard) ? entry : undefined + } + let params: GuardParams | undefined + for (const el of entry) { + if (typeof el === 'function') return { actions: el } + if (!el.guard) return el + params ??= makeGuardParams(host.context, event, host.computed, host.guards) + if (params.guard(el.guard)) return el + } + return undefined +} diff --git a/packages/core/src/wasm.ts b/packages/core/src/wasm.ts deleted file mode 100644 index c4d24a2..0000000 --- a/packages/core/src/wasm.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The engine: Rust (crates/core-wasm) compiled to wasm, built by `pnpm build:wasm`. The - * bytes are inlined, so it starts synchronously — no `await init()` — on first use. - */ -import { engineHeapBytes, initSync, JsConfig, JsMachine } from '../wasm/dunky_core_wasm.js' -import { WASM } from '../wasm/bytes.js' - -let started = false - -/** Instantiate the engine once. Lazy, so importing the package costs nothing. */ -export function startEngine(): void { - if (started) return - const binary = atob(WASM) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) - initSync({ module: bytes }) - started = true -} - -// `engineHeapBytes`: the engine's live heap, for the benchmark (not public API). -export { engineHeapBytes, JsConfig, JsMachine } diff --git a/packages/core/tests/computed.test.ts b/packages/core/tests/computed.test.ts index 44c1d01..30b3e34 100644 --- a/packages/core/tests/computed.test.ts +++ b/packages/core/tests/computed.test.ts @@ -58,50 +58,6 @@ describe('from context', () => { expect(seen).toEqual([true, false]) }) - it('a read right after a write, in the same action, sees the write', () => { - let seen: number | undefined - const m = machine<'idle', { n: number }, { type: 'inc' }, { double: number }>({ - initial: 'idle', - context: { n: 1 }, - computed: { double: ({ context }) => context.n * 2 }, - states: { - idle: { - on: { - inc: { - actions: [ - ({ context, setContext, computed }) => { - setContext({ n: context.n + 1 }) - seen = computed.double - }, - ], - }, - }, - }, - }, - }) - expect(m.computed.double).toBe(2) // cached before the write - m.send({ type: 'inc' }) - expect(seen).toBe(4) - }) - - it('a definition that throws rethrows on read, and runs again on the next read', () => { - let ready = false - const m = machine<'idle', { n: number }, { type: 'noop' }, { checked: number }>({ - initial: 'idle', - context: { n: 1 }, - computed: { - checked: ({ context }) => { - if (!ready) throw new Error('not ready') - return context.n - }, - }, - states: { idle: {} }, - }) - expect(() => m.computed.checked).toThrow('not ready') - ready = true - expect(m.computed.checked).toBe(1) - }) - it('is available to a guard', () => { const m = machine< 'idle' | 'done', @@ -297,31 +253,6 @@ describe('chaining — computed from computed', () => { m.send({ type: 'read' }) expect([baseRuns, derivedRuns]).toEqual([2, 2]) }) - - it('a chain updates after an upstream value that stayed equal', () => { - const m = machine< - 'idle', - { n: number }, - { type: 'set'; n: number }, - { parity: string; label: string; shout: string } - >({ - initial: 'idle', - context: { n: 1 }, - computed: { - parity: ({ context }) => (context.n % 2 ? 'odd' : 'even'), - label: ({ computed }) => `n is ${computed.parity}`, - shout: ({ computed }) => computed.label.toUpperCase(), - }, - states: { - idle: { on: { set: { actions: [({ event, setContext }) => setContext({ n: event.n })] } } }, - }, - }) - expect(m.computed.shout).toBe('N IS ODD') - m.send({ type: 'set', n: 3 }) // parity stays 'odd', so label and shout keep their values - expect(m.computed.shout).toBe('N IS ODD') - m.send({ type: 'set', n: 4 }) - expect(m.computed.shout).toBe('N IS EVEN') - }) }) describe('surfaced on the machine', () => { diff --git a/packages/core/tests/from-wasm.test.ts b/packages/core/tests/from-wasm.test.ts deleted file mode 100644 index e8d8953..0000000 --- a/packages/core/tests/from-wasm.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * `fromWasm` against a scripted handle that plays the Rust side of the protocol: - * events in by kind, a notify per change naming the changed fields, computed values - * served by identity. The facade behavior both machine kinds share (subscribe, select, - * lifecycle, timers) is pinned by the TS-machine tests. - */ -import { describe, expect, it, vi } from 'vitest' -import { fromWasm, type WasmMachine, type WasmMachineMeta } from '../src' -import type { HOST } from '../src/machine' - -const FIELDS = Array.from({ length: 40 }, (_, i) => `f${i}`) - -function fake() { - let host: typeof HOST - // Rust calls the host only during a call into it: a change waits for the next event. - let pending: (() => void) | null = null - const rust = { - state: 0, - values: FIELDS.map(() => 0), - computed: [[2, 0], 'other'] as unknown[], - } - const reads = { field: 0 } - const handle = { - attach: (h: object) => { - host = h as typeof HOST - return 0 - }, - send: vi.fn((_kind: number, _event: unknown) => { - pending?.() - pending = null - return 0 - }), - start: () => 0, - stop: () => 0, - running: () => false, - state: () => rust.state, - computed: (id: number) => rust.computed[id], - fire: vi.fn(() => 0), - takeFailure: () => undefined, - field: (i: number) => { - reads.field++ - return rust.values[i] - }, - meta: (): WasmMachineMeta => ({ - states: ['idle', 'busy'], - events: ['go', 'set'], - fields: FIELDS, - computed: ['indices', 'other'], - tags: [[], ['working']], - }), - } satisfies WasmMachine - /** On the next event, Rust changes the state and the fields at `changed`. */ - const change = (state: number, changed: Record) => { - pending = () => { - rust.state = state - let lo = 0 - let hi = 0 - for (const [i, value] of Object.entries(changed)) { - rust.values[Number(i)] = value - if (Number(i) < 32) lo |= 1 << Number(i) - else hi |= 1 << (Number(i) - 32) - } - host.notify(state, lo >>> 0, hi >>> 0) - } - } - return { handle, rust, reads, change } -} - -type Ctx = Record - -describe('fromWasm', () => { - it('sends events by kind, with the event object for the payload; ignores unknown types', () => { - const { handle } = fake() - const m = fromWasm<'idle' | 'busy', Ctx, { type: string; v?: number }>(handle) - m.send({ type: 'go' }) - m.send({ type: 'set', v: 1 }) - m.send({ type: 'nope' }) - expect(handle.send.mock.calls.map(([kind, event]) => [kind, event])).toEqual([ - [0, { type: 'go' }], - [1, { type: 'set', v: 1 }], - ]) - }) - - it('moves the state on each notify and re-reads only the fields it names', () => { - const { handle, reads, change } = fake() - const m = fromWasm<'idle' | 'busy', Ctx, { type: 'go' }>(handle) - const context = m.context - const listener = vi.fn(() => [m.state, m.context.f1, m.context.f35]) - m.subscribe(listener) - reads.field = 0 - change(1, { 1: 7, 35: 9 }) - m.send({ type: 'go' }) - expect(listener).toHaveReturnedWith(['busy', 7, 9]) - expect(m.hasTag('working')).toBe(true) - expect(reads.field).toBe(2) - expect(m.context).toBe(context) - }) - - it('maps a computed value only when Rust serves a new one; `from` reads another value', () => { - const { handle, rust } = fake() - const own = ['x', 'y', 'z'] - const map = vi.fn((raw: unknown) => (raw as number[]).map(i => own[i]!)) - const m = fromWasm<'idle', Ctx, { type: 'go' }, { indices: string[]; other: string[] }>( - handle, - { computed: { indices: map, other: { from: 'indices', map } } }, - ) - expect(m.computed.indices).toEqual(['z', 'x']) - expect(m.computed.indices).toBe(m.computed.indices) - expect(map).toHaveBeenCalledTimes(1) - rust.computed[0] = [1] - expect(m.computed.indices).toEqual(['y']) - expect(m.computed.other).toEqual(['y']) - }) -}) diff --git a/packages/react/SPEC.md b/packages/react/SPEC.md deleted file mode 100644 index 5d5b986..0000000 --- a/packages/react/SPEC.md +++ /dev/null @@ -1,208 +0,0 @@ -# SPEC - `@dunky.dev/react-state-machine` - -## Overview - -The React target runs one core machine inside one React component. The -behavior lives in the core machine and in the component's `connect`. This -package adds only what React needs: a lifecycle hook, a selector hook, a -translator from bindings to DOM props, and a prop merger. - -``` - machine source connect component effects props - (config or machine) (pure) (static list) (each render) - | | | | - v v v v - +-------------------------------------------------------------------+ - | the lifecycle hook - one call per component instance | - | | - | first render ..... build the machine + connector, once | - | mount ............ start the machine; unmount: stop it | - | after a render ... push the current props into the connector | - | any change ....... re-render from the connector's surface | - | each effect ...... re-run it when its named props change | - +-------------------------------------------------------------------+ - | | - | surface (api) | machine - v v - translator: bindings --> DOM props the selector hook: a leaf - | re-renders on one value - v - -``` - -## Intent - -Bridge one machine into React with as little React as possible. A -component's behavior files stay free of React: the machine, `connect`, and -the component effects are plain values. The lifecycle hook makes every -React hook call for them. - -The bridge makes three promises. The machine is built once and lives as -long as the component. The view renders from a stable surface, so React -never tears and never loops. A leaf can subscribe to one value, so a change -wakes only the readers of that value (`O(readers)`). - -## Scope & boundaries - -**In scope.** The lifecycle hook, the selector hook, the translator from -bindings to React DOM props, and the prop merger. - -**Out of scope — invariants, not preferences.** - -- **No state of its own.** The target reads the machine through the - connector. It does not mirror context, fork the state graph, or shadow - transitions. New state goes in core. -- **The machine never sees props.** The first props reach the source - factory once. After that, props reach the connector and the component - effects, never the machine. See - [the core rule](../core/SPEC.md#the-machine-never-sees-props). -- **Platform quirks live here, as component effects.** An effect that needs - props or a platform API (a DOM `keydown`, a `ResizeObserver`) is a - component effect. Core makes the decision; the effect only listens and - sends a plain event. Props-free, platform-free effects stay in the core - config (see - [Two homes for a side-effect](../../ARCHITECTURE.md#two-homes-for-a-side-effect)). -- **The hooks use React only.** They touch no DOM API, because other React - renderers reuse them: the React Native target re-exports both hooks, and - the OpenTUI sandbox pairs them with the OpenTUI translator. Only the - translator and the prop merger are DOM-specific. - -## The behavior contract - -These are the guarantees the bridge gives. They describe what it does, not -the names it exports. - -### The machine source - -- The first argument is a factory. The hook calls it with the first - render's props. It returns a **config** or a **ready machine** (for - example, a Rust machine wrapped with `fromWasm`). -- A config is built into a stopped machine. A ready machine is used as is: - the hook returns that same instance. A target cannot tell the two kinds - apart (see [core Intent](../core/SPEC.md#intent)). -- The hook owns the machine's lifecycle in both cases. So a ready machine - must come fresh from the factory, one per component instance. Sharing one - machine between components is not supported: when one instance unmounts, - it stops the machine for all of them. - -### Lifecycle - -- **Build once.** The machine and its connector are built on the first - render and never rebuilt. A rebuild would lose state. -- **Start on mount, stop on unmount.** A StrictMode mount, unmount, and - mount runs start, stop, start. This is safe because a stop keeps state - and a restart boots from the current state (see - [core Lifecycle](../core/SPEC.md#lifecycle)). -- **The hook never destroys the connector.** The connector shares the - machine's lifetime. A destroy on unmount would break the StrictMode - remount, which reuses the same memoized connector. -- **Reactions follow the machine.** The connector wires its reactions on - start and removes them on stop (see - [the connector](../core/SPEC.md#the-view-boundary--the-connector)). So a - prop callback fires only while the component is mounted, and never on - subscribe. - -### Props - -- The first render's props seed the machine (through the factory) and the - connector. -- After each render commits, the hook pushes the current props into the - connector. It never does this during render: a store write mid-render - notifies React's external-store subscription and loops. -- The connector ignores props that are shallow-equal to the current props. - A re-render with equal props keeps the same surface. - -### Rendering - -- The component re-renders on every machine change and on every real props - change. React reads the connector's memoized surface through its - external-store subscription. -- The surface keeps its identity between changes, so React never tears and - never loops. -- The hook returns the surface and the machine. The view renders from the - surface. The machine is for sending events and for the selector hook. - -### Component effects - -- A component effect is a pair: a setup function of the machine and the - props, which can return a cleanup; and the names of the props it depends - on. The names are typed against the props, so a typo does not compile. -- Each effect runs as its own React effect. Setup runs after mount. Cleanup - runs on unmount. -- An effect re-runs (cleanup, then setup) only when one of its own named - props changes. A change to any other prop does not re-run it, not even a - new callback identity. -- The effect list must be a static module constant. Each entry is one React - hook call, and React forbids a changing hook count. -- An effect sees the props of the render that last ran it. If it reads a - prop later (for example, a callback inside a listener), that prop must be - in its dep list. If not, the effect reads a stale value. -- The machine that an effect gets is live: it can read the current state. - -### Fine-grained selection - -- The selector hook subscribes a leaf to one value read from a machine. The - leaf re-renders only when that value changes: by identity, or by a - supplied equality. -- A change elsewhere in the machine, or an event that changes nothing, does - not re-render the leaf. A change wakes only the leaves whose value - changed. -- A fresh selector closure on each render is correct. The hook always - evaluates the latest closure, so a selector can close over a prop. -- A selector that returns a fresh object or array on each call must supply - an equality. With one, the hook returns the same object until a real - change. Without one, every read looks new and the leaf re-renders in a - loop. - -### Translating bindings - -- The translator turns the connector's agnostic bindings into React DOM - props. It is the only place where a binding becomes a DOM prop. -- Handlers take their DOM names: `onPress` becomes `onClick`, - `onValueChange` becomes `onChange`, and `onDoublePress` becomes - `onDoubleClick`. The other handlers keep their names. -- A handler whose agnostic payload differs from the DOM event (value - change, wheel, scroll, scroll end) is wrapped, so the consumer's handler - gets the agnostic payload. Other handlers pass through unchanged. -- An ARIA-derived attribute becomes `aria-*`, and its value does not change - (booleans, the `'mixed'` tristate, enums). `focusable` becomes `tabIndex`: - `0` when true, `-1` when false. -- An `undefined` value is dropped. An unknown key (`data-*`) passes through. -- The DOM can express every key in the vocabulary, so this target drops - none. The shared conformance suite checks where each key lands. -- The DOM half that the Solid target also uses (the `aria-*` map and the - payload adapters) lives in `@dunky.dev/state-machine-dom`. This package - adds only React's own prop names. - -### Merging consumer props - -- The prop merger combines the consumer's props with the component's - translated props for one element. -- Handlers (`on` followed by an uppercase letter) chain, consumer first. If - the consumer's handler marks the event `defaultPrevented`, the library - handler is skipped. -- `className` concatenates when both sides are strings: one space between, - edges trimmed. -- `style`: when both sides set it, both apply, and the library wins on a - conflicting key. -- Every other key: the library wins, because the component owns its - semantics (`id`, `role`, `aria-*`). A key that only the consumer sets - stays. - -## Edge cases that carry design meaning - -- In StrictMode development, React runs the build twice on the first render - and keeps one result. The other machine is never started. So the factory - must only build and return. -- A props change reaches the surface one render later. The render that - carries the new props still shows the old surface. The connector's wake - then schedules a render with the new surface. - -## Reference - -- [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) — the attribute - vocabulary that the translator maps to `aria-*`. See - [ACCESSIBILITY.md](../../ACCESSIBILITY.md). -- React [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore) - and [`StrictMode`](https://react.dev/reference/react/StrictMode) — the - contracts that the lifecycle hook relies on. diff --git a/packages/react/src/use-machine.ts b/packages/react/src/use-machine.ts index 74b11af..541d6f8 100644 --- a/packages/react/src/use-machine.ts +++ b/packages/react/src/use-machine.ts @@ -1,11 +1,5 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { - connector, - toMachine, - type Connect, - type Machine, - type MachineSource, -} from '@dunky.dev/state-machine' +import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' /** * A substrate-specific effect: a setup/teardown function plus the prop names it depends on. @@ -25,14 +19,9 @@ export type ComponentEffect = [ ] /** - * The generic React bridge. Builds the machine once from the first render's props — - * from a config, or as a ready machine (e.g. a Rust machine via wasm) — + * The generic React bridge. Builds the machine once from the first render's props, * keeps props fresh via setProps, runs substrate effects, and drives React via * useSyncExternalStore over the connector's snapshot. - * - * A ready machine must come fresh from `createConfig` (one per component instance): - * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine - * between components is not supported. */ export function useMachine< State extends string, @@ -42,14 +31,14 @@ export function useMachine< Api, Computed = Record, >( - createConfig: (props: Props) => MachineSource, + createConfig: (props: Props) => TransitionConfig, connect: Connect, - effects: ComponentEffect, Props>[], + effects: ComponentEffect>, Props>[], props: Props, -): { api: Api; machine: Machine } { +): { api: Api; machine: ReturnType> } { const { service, connection } = useMemo( () => { - const service = toMachine(createConfig(props)) + const service = machine(createConfig(props)) const connection = connector(service, connect, props) return { service, connection } }, diff --git a/packages/react/tests/use-machine.test.tsx b/packages/react/tests/use-machine.test.tsx index 48b2cc3..996aa71 100644 --- a/packages/react/tests/use-machine.test.tsx +++ b/packages/react/tests/use-machine.test.tsx @@ -117,25 +117,6 @@ describe('useMachine — lifecycle', () => { }) }) -describe('useMachine — machine source', () => { - it('accepts a ready machine instead of a config and runs that very instance', () => { - // The shape a Rust machine takes (built outside the engine, e.g. via wasm). - const ready = machine(createConfig()({})) - const sink: { api?: ToggleApi; machine?: ToggleMachine } = {} - function Comp(p: ToggleProps) { - const result = useMachine(() => ready, connect, noEffects, p) - sink.api = result.api - sink.machine = result.machine - return null - } - render() - expect(sink.machine).toBe(ready) - act(() => sink.api!.toggle()) - expect(ready.state).toBe('open') - expect(sink.api!.open).toBe(true) - }) -}) - describe('useMachine — build once', () => { it('builds the machine ONCE: state survives prop changes (no rebuild)', () => { const { sink, Comp } = harness({ label: 'a' }) diff --git a/packages/solid/SPEC.md b/packages/solid/SPEC.md deleted file mode 100644 index 1a01341..0000000 --- a/packages/solid/SPEC.md +++ /dev/null @@ -1,212 +0,0 @@ -# SPEC - `@dunky.dev/solid-state-machine` - -## Overview - -The Solid target runs one core machine inside one Solid component. It is a -first-class target built on Solid 2.0's own reactivity, not a port of the -React bridge. It has the same four parts as the React target (a lifecycle -hook, a selector hook, a translator from bindings to DOM props, and a prop -merger), and it takes the same machine, `connect`, and component effects. - -``` - machine source connect component effects reactive props - (config or machine) (pure) (static list) (Solid proxy) - | | | | - v v v v - +-------------------------------------------------------------------+ - | the lifecycle hook - the component body calls it once | - | | - | call ........... build the machine + connector | - | settled ........ start the machine; disposal: stop it | - | a prop change .. push a plain copy of the props | - | a wake ......... reconcile the new surface into a Solid store | - | each effect .... run it; re-run it when its named props change | - +-------------------------------------------------------------------+ - | | - | api (store proxy) | machine - v v - translator: bindings --> DOM props the selector hook: an accessor - | that changes on one value - v - -``` - -## Intent - -Bridge one machine into Solid with Solid's own tools: a store that mirrors -the surface, two-phase effects, and the owner's settle and cleanup. There is -no re-render and no external-store hook. - -The bridge is fine-grained by default. A read of one surface field -subscribes to that field only, so an unrelated change never touches it. A -component effect has the same shape on every target, so it is written once -and runs unchanged on React and Solid. - -## Scope & boundaries - -**In scope.** The lifecycle hook, the selector hook, the translator from -bindings to Solid DOM props, and the prop merger. - -**Out of scope — invariants, not preferences.** - -- **No state of its own.** The store mirrors the connector's surface. The - hook writes to it only from the connector, on each wake. New state goes - in core. -- **The machine never sees props.** The first props reach the source - factory once. After that, props reach the connector and the component - effects, never the machine. See - [the core rule](../core/SPEC.md#the-machine-never-sees-props). -- **Platform quirks live here, as component effects.** Core makes the - decision; the effect only listens and sends a plain event. Props-free, - platform-free effects stay in the core config (see - [Two homes for a side-effect](../../ARCHITECTURE.md#two-homes-for-a-side-effect)). -- **Solid 2.0 only.** The peer range is `solid-js` `^2.0.0-rc.1`. Solid 1.x - is not supported. Solid 2.0 removed the 1.x surface (`solid-js/store`, - one-phase effects, `onMount`), and 1.x lacks the root exports that this - package imports. One code path cannot serve both majors. - -## The behavior contract - -These are the guarantees the bridge gives. They describe what it does, not -the names it exports. - -### The machine source - -- The first argument is a factory. The hook calls it once, with the - component's props. It returns a **config**, which is built into a stopped - machine, or a **ready machine** (for example, a Rust machine wrapped with - `fromWasm`), which is used as is: the hook returns that same instance. -- The hook owns the machine's lifecycle in both cases. So a ready machine - must come fresh from the factory, one per component instance. Sharing one - machine between components is not supported. - -### Lifecycle - -- **Build once.** A Solid component body runs once, so a plain call builds - once. A later prop change never rebuilds the machine. -- **Start when rendering settles. Stop on disposal.** A stop keeps state - (see [core Lifecycle](../core/SPEC.md#lifecycle)). -- **The hook never destroys the connector.** The connector and the machine - share the component's lifetime and are collected together. -- **Reactions follow the machine.** The connector wires them on start and - removes them on stop. A reaction never fires on subscribe. - -### Props - -- The hook takes Solid's reactive props object. -- The connector gets a plain copy of the props, never the live proxy. The - connector compares each update with the props it holds. A held proxy - always equals a fresh copy of itself, because its getters already return - the new values. So the connector would never wake. -- A tracked effect reads every prop. When any prop changes, it pushes a new - copy to the connector. There is no manual dep list. The connector ignores - a copy that is shallow-equal to the current props. - -### The surface — a fine-grained store - -- The hook mirrors the connector's surface into a Solid store. On each - connector wake, it reconciles the new surface into the store. Only the - leaves that changed notify their readers. -- A read of one field, in JSX or in a tracked scope, subscribes to that - field only. A change to another field does not re-run that reader. -- The returned `api` is the store proxy. Do not destructure it: that reads - each value once and drops reactivity. Read a field where you use it. -- Function leaves (handlers, part getters such as `getItemProps`) stay - callable and live across updates, although `connect` builds new closures - on each wake. - -### Component effects - -- A component effect is the same pair as on every target: a setup function - of the machine and the props, which can return a cleanup; and the names - of the props it depends on, typed against the props. -- Each effect is one two-phase Solid effect. The tracking phase reads - exactly the named props. The setup runs in the untracked phase. -- So the dep list is the whole re-run contract. A prop that the setup only - reads never becomes a hidden dependency. A change to a prop outside the - list does not re-run the effect. -- Setup runs after mount, and cleanup on disposal. On a dep change, the - previous cleanup runs before the next setup, so a listener never stacks. -- The effect gets the hook's reactive props object, not a copy. A read - inside a listener sees the current value, and the read does not re-run - the effect. -- The machine that an effect gets is live: it can read the current state. - -### Fine-grained selection - -- The selector hook returns a Solid accessor for one value read from a - machine. The accessor changes only when that value changes: by identity, - or by a supplied equality. -- A change elsewhere in the machine, or an event that changes nothing, does - not update the accessor. A change wakes only the readers whose value - changed. -- A selected function comes back by identity and is never called. A Solid - 2.0 signal treats a function argument as a compute, so the hook seeds and - writes the signal through the function form. -- The subscription belongs to the owner. When the owner is disposed, the - machine stops evaluating the selector. -- An object selection must supply an equality. If not, every machine change - pushes a new value, even when the content is equal. -- `api` is already fine-grained. The selector hook is for a leaf that - tracks one value of a machine it does not own, such as many rows. - -### Translating bindings - -- The translator turns the agnostic bindings into DOM props as Solid's JSX - expects them. It uses the same vocabulary and the same shared DOM half - (`@dunky.dev/state-machine-dom`) as the React target. Only Solid's own - names and values differ: - - `onValueChange` becomes `onInput`, because Solid's `onChange` fires only - on commit. - - `onDoublePress` becomes `onDblClick`. - - `focusable` becomes the lowercase `tabindex`: `0` when true, `-1` when - false. It is not a boolean, because `false` must still let script focus - the element. - - A boolean ARIA value becomes the string `"true"` or `"false"`. Solid 2.0 - renders a boolean attribute as present or absent, but ARIA states are - literal tokens. Other values (`'mixed'`, enums, numbers) do not change. -- Handlers get native events, not synthetic events. The payload adapters - read the same fields on both, so a wrapped handler (value change, wheel, - scroll, scroll end) gets the agnostic payload. -- An `undefined` value is dropped. An unknown key (`class`, `data-*`) passes - through. This target drops no vocabulary key; the shared conformance - suite checks where each key lands. - -### Merging consumer props - -- Handlers chain, consumer first. If the consumer's handler marks the event - `defaultPrevented`, the library handler is skipped. -- `class` (not `className`) concatenates when both sides are strings: one - space between, edges trimmed. -- `style` merges into one object when both sides are objects. The library - wins on a conflicting key. If either side is a string, the library's - value wins. -- Every other key: the library wins. A key that only the consumer sets - stays. -- This is not Solid's own `mergeProps`, which merges reactive prop objects. - -### Solid 2.0 timing - -- Solid 2.0 commits signal writes, effects, and DOM updates on the - microtask queue. JSX readers always settle. -- After an event, a plain read of `api` sees the new value at once. A - selector accessor read in the same tick returns the previous value until - the queue flushes. So tests call `flush()` from `solid-js` before they - check the DOM or an accessor. -- JSX types and `render` come from `@solidjs/web` - (`"jsxImportSource": "@solidjs/web"`), not from `solid-js/web`. - -## Edge cases that carry design meaning - -- Function leaves stay live across updates. The peer floor is `2.0.0-rc.1` - because the `reconcile` in `2.0.0-rc.0` corrupted them. -- After disposal, an event can still move the machine, but no prop callback - fires. - -## Reference - -- [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) — the attribute - vocabulary that the translator maps to `aria-*`. See - [ACCESSIBILITY.md](../../ACCESSIBILITY.md). -- [Solid](https://github.com/solidjs/solid) `2.0.0-rc.1` — the peer floor - and the reactive model that this bridge is built on. diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index 052cafb..844f393 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -1,11 +1,5 @@ import { createEffect, createStore, onCleanup, onSettled, reconcile } from 'solid-js' -import { - connector, - toMachine, - type Connect, - type Machine, - type MachineSource, -} from '@dunky.dev/state-machine' +import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' /** * One substrate-specific effect: a setup/teardown function plus the prop names @@ -19,15 +13,10 @@ export type ComponentEffect = [ ] /** - * The generic Solid bridge: builds the machine (from a config, or a ready machine such - * as a Rust machine via wasm) + connector once, mirrors the + * The generic Solid bridge: builds the machine + connector once, mirrors the * connector's snapshot into a fine-grained store, runs the lifecycle and the * component's effects. Returns the connect() api (reactive store proxy) and * the running machine. - * - * A ready machine must come fresh from `createConfig` (one per component instance): - * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine - * between components is not supported. */ export function useMachine< State extends string, @@ -37,14 +26,14 @@ export function useMachine< Api extends object, Computed = Record, >( - createConfig: (props: Props) => MachineSource, + createConfig: (props: Props) => TransitionConfig, connect: Connect, - effects: ComponentEffect, Props>[], + effects: ComponentEffect>, Props>[], props: Props, -): { api: Api; machine: Machine } { +): { api: Api; machine: ReturnType> } { // Seed with a plain copy, never the live props proxy: setProps value-dedups, // and a held proxy would compare equal to its own fresh spread and never wake. - const service = toMachine(createConfig(props)) + const service = machine(createConfig(props)) const connection = connector(service, connect, { ...props }) // Fine-grained mirror of the snapshot: reading `api.x` subscribes to that diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx index f4b449a..47f5481 100644 --- a/packages/solid/tests/use-machine.test.tsx +++ b/packages/solid/tests/use-machine.test.tsx @@ -68,25 +68,6 @@ const noEffects: ComponentEffect[] = [] afterEach(() => vi.clearAllMocks()) -describe('useMachine — machine source', () => { - it('accepts a ready machine instead of a config and runs that very instance', () => { - // The shape a Rust machine takes (built outside the engine, e.g. via wasm). - const ready = machine(createConfig()({})) - let captured: { api: ToggleApi; machine: ToggleMachine } | undefined - function Comp() { - const props: ToggleProps = {} - captured = useMachine(() => ready, connect, noEffects, props) - return null - } - render(() => ) - expect(captured!.machine).toBe(ready) - captured!.api.toggle() - flush() - expect(ready.state).toBe('open') - expect(captured!.api.open).toBe(true) - }) -}) - describe('useMachine — lifecycle', () => { it('returns { api, machine }: api is the connect() output, machine is the running service', () => { let captured: { api: ToggleApi; machine: ToggleMachine } | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11598fc..0d1998c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,9 +53,6 @@ importers: '@dunky.dev/state-machine': specifier: workspace:^ version: link:../packages/core - '@sandbox/cmdk-core': - specifier: workspace:* - version: link:../sandbox/shared devDependencies: '@types/jsdom': specifier: ^21.1.7 diff --git a/sandbox/README.md b/sandbox/README.md index d1f70f6..4f45dc5 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -38,25 +38,8 @@ pnpm -C sandbox/solid dev pnpm -C sandbox/opentui dev # Native — needs Expo + an iOS/Android simulator or device. -pnpm -C sandbox/native dev # then press i / a, or scan the QR +pnpm -C sandbox/native start # then press i / a, or scan the QR ``` All four consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. - -## The same palette, written in Rust - -Both palettes run on the Rust engine. By default the sandboxes run the one written in -TypeScript (`shared/src/machine.ts`); each web and terminal sandbox can also run its -Rust twin (`shared/rust`, wrapped with `fromWasm`) — same `connect()`, same markup, -only the machine's source language differs: - -```bash -pnpm build:wasm # once: Rust → wasm (see CONTRIBUTING.md) -pnpm -C sandbox/react dev # then open http://localhost:5173/?machine=rust -pnpm -C sandbox/solid dev # then open http://localhost:5173/?machine=rust -pnpm -C sandbox/opentui dev:rust # Bun runs the wasm -``` - -React Native has no WebAssembly (Hermes), so the native sandbox waits for the engine -over JSI. diff --git a/sandbox/native/app.json b/sandbox/native/app.json index 43c582e..9563fad 100644 --- a/sandbox/native/app.json +++ b/sandbox/native/app.json @@ -6,7 +6,6 @@ "orientation": "portrait", "userInterfaceStyle": "light", "newArchEnabled": true, - "experiments": { "tsconfigPaths": false }, "ios": { "supportsTablet": true, "bundleIdentifier": "dev.dunky.cmdk" }, "android": { "package": "dev.dunky.cmdk" } } diff --git a/sandbox/opentui/package.json b/sandbox/opentui/package.json index 15a2da3..d80f031 100644 --- a/sandbox/opentui/package.json +++ b/sandbox/opentui/package.json @@ -4,8 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run src/index.tsx", - "dev:rust": "bun run src/index.tsx --machine=rust" + "dev": "bun run src/index.tsx" }, "dependencies": { "@dunky.dev/opentui-state-machine": "workspace:^", diff --git a/sandbox/opentui/src/app.tsx b/sandbox/opentui/src/app.tsx index dfc0b36..1cf0a55 100644 --- a/sandbox/opentui/src/app.tsx +++ b/sandbox/opentui/src/app.tsx @@ -1,11 +1,8 @@ import { useState } from 'react' -import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -/** The language the palette machine is written in. Both run on the Rust engine. */ -export type Language = 'ts' | 'rust' - -export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { +export function App() { const [last, setLast] = useState('—') return ( @@ -25,8 +22,7 @@ export function App({ language, source }: { language: Language; source: CommandP NAMED ANSI color ('green'), which the terminal maps to its own palette. */} ⌘K Command Pallete - setLast(c.label)} /> - Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} + setLast(c.label)} /> One state machine drives this ⌘K palette. diff --git a/sandbox/opentui/src/command-palette.tsx b/sandbox/opentui/src/command-palette.tsx index 8d215ed..f8ae000 100644 --- a/sandbox/opentui/src/command-palette.tsx +++ b/sandbox/opentui/src/command-palette.tsx @@ -7,9 +7,9 @@ import type { KeyEvent } from '@opentui/core' import { useMachine } from '@dunky.dev/react-state-machine' import { normalize } from '@dunky.dev/opentui-state-machine' import { + commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, - type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -54,12 +54,8 @@ const paletteKeys = (machine: CommandPaletteMachine) => (key: KeyEvent) => { // the SAME shared machine, `connect` produces the SAME logical bindings — only // `normalize` (from the opentui package) and the JSX elements differ. The index // math, filtering, and selection all live in the shared machine, unchanged. -// `source` is the TS config or the Rust machine (see index.tsx); nothing else differs. -export function CommandPalette({ - source, - ...props -}: CommandPaletteProps & { source: CommandPaletteSource }) { - const { api, machine } = useMachine(source, connectCommandPalette, [], props) +export function CommandPalette(props: CommandPaletteProps) { + const { api, machine } = useMachine(commandPaletteMachineConfig, connectCommandPalette, [], props) // Starts closed — press Ctrl+K to open. (⌘K can't be used in a terminal: // macOS/Ghostty don't forward Cmd to the program — Cmd is an app/OS modifier — diff --git a/sandbox/opentui/src/index.tsx b/sandbox/opentui/src/index.tsx index 951fe3b..45724b3 100644 --- a/sandbox/opentui/src/index.tsx +++ b/sandbox/opentui/src/index.tsx @@ -1,19 +1,6 @@ import { createCliRenderer } from '@opentui/core' import { createRoot } from '@opentui/react' -import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' -import { App, type Language } from './app' - -// `--machine=rust` runs the palette written in Rust (sandbox/shared/rust → wasm) instead -// of the one written in TS. Build the wasm first: `pnpm build:wasm`. -const language: Language = process.argv.includes('--machine=rust') ? 'rust' : 'ts' - -let source: CommandPaletteSource = commandPaletteMachineConfig -if (language === 'rust') { - // Imported only for the Rust machine, so the TS app loads no second wasm module. - const rust = await import('@sandbox/cmdk-core/rust-node') - rust.loadRustNode() - source = props => rust.createRustPalette(props.commands) -} +import { App } from './app' const renderer = await createCliRenderer() -createRoot(renderer).render() +createRoot(renderer).render() diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 3603605..38b4005 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -1,11 +1,8 @@ import { useState } from 'react' -import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -/** The language the palette machine is written in. Both run on the Rust engine. */ -export type Language = 'ts' | 'rust' - -export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { +export function App() { const [last, setLast] = useState('—') return ( @@ -13,7 +10,6 @@ export function App({ language, source }: { language: Language; source: CommandP

⌘K Command Palette


{ setLast(c.label) @@ -29,12 +25,6 @@ export function App({ language, source }: { language: Language; source: CommandP

Last selected: {last}

-

- Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} - - Switch to {language === 'rust' ? 'TypeScript' : 'Rust'} - -

) } diff --git a/sandbox/react/src/command-palette.tsx b/sandbox/react/src/command-palette.tsx index 924a1d4..4b2746a 100644 --- a/sandbox/react/src/command-palette.tsx +++ b/sandbox/react/src/command-palette.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/react-state-machine' import { + commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, - type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -28,12 +28,13 @@ const cmdkShortcut: ComponentEffect // machine, `connect` produces logical bindings, and `normalize` turns them into // DOM props (onPress→onClick, role/aria-*, etc). The component is just markup; // the look lives in the stylesheet shared with the Solid app. -// `source` is the TS config or the Rust machine (see main.tsx); nothing else differs. -export function CommandPalette({ - source, - ...props -}: CommandPaletteProps & { source: CommandPaletteSource }) { - const { api } = useMachine(source, connectCommandPalette, [cmdkShortcut], props) +export function CommandPalette(props: CommandPaletteProps) { + const { api } = useMachine( + commandPaletteMachineConfig, + connectCommandPalette, + [cmdkShortcut], + props, + ) const inputRef = useRef(null) // Focus the input whenever the palette opens (a renderer concern, not the diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 8ef6694..4950347 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -1,7 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' -import { App, type Language } from './app' +import { App } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -9,24 +8,8 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -// `?machine=rust` runs the palette written in Rust (sandbox/shared/rust → wasm) instead -// of the one written in TS. Build the wasm first: `pnpm build:wasm`. -const language: Language = - new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' - -async function boot(target: HTMLElement) { - let source: CommandPaletteSource = commandPaletteMachineConfig - if (language === 'rust') { - // Imported only for the Rust machine, so the TS page loads no second wasm module. - const rust = await import('@sandbox/cmdk-core/rust') - await rust.loadRust() - source = props => rust.createRustPalette(props.commands) - } - createRoot(target).render( - - - , - ) -} - -void boot(root) +createRoot(root).render( + + + , +) diff --git a/sandbox/shared/package.json b/sandbox/shared/package.json index 24cd2ff..425d0d2 100644 --- a/sandbox/shared/package.json +++ b/sandbox/shared/package.json @@ -6,9 +6,7 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./rust": "./src/rust.ts", - "./rust-node": "./src/rust-node.ts" + ".": "./src/index.ts" }, "peerDependencies": { "@dunky.dev/state-machine": "workspace:^", diff --git a/sandbox/shared/rust/Cargo.toml b/sandbox/shared/rust/Cargo.toml deleted file mode 100644 index f693385..0000000 --- a/sandbox/shared/rust/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "dunky-sandbox" -description = "The sandbox machines written in Rust (the command palette, a dialog), exported to wasm for every sandbox through fromWasm." -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[features] -# The JS classes (export_machine!), on for the wasm build (pnpm build:wasm) only, so -# crates reusing the machines (the benchmark) never link a second set of exports. -wasm = ["dep:dunky-wasm", "dep:wasm-bindgen"] - -[dependencies] -dunky-core = { workspace = true } -dunky-wasm = { workspace = true, optional = true } -serde = { workspace = true } -wasm-bindgen = { workspace = true, optional = true } - -[dev-dependencies] -serde_json = { workspace = true } - -[[example]] -name = "typescript" -required-features = ["wasm"] diff --git a/sandbox/shared/rust/examples/typescript.rs b/sandbox/shared/rust/examples/typescript.rs deleted file mode 100644 index 303b90b..0000000 --- a/sandbox/shared/rust/examples/typescript.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Prints the TS types of the exported classes; `pnpm build:wasm` appends them to the -//! module's `.d.ts`, where `fromWasm` reads them. - -fn main() { - print!("{}", dunky_sandbox::typescript()); -} diff --git a/sandbox/shared/rust/src/lib.rs b/sandbox/shared/rust/src/lib.rs deleted file mode 100644 index 3f08ac9..0000000 --- a/sandbox/shared/rust/src/lib.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! The sandbox machines, written in Rust against `dunky-core`: -//! -//! - [`palette`] — the command palette, a faithful port of `sandbox/shared/src/machine.ts` -//! (computed filtering, logical navigation). -//! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). -//! -//! Built to wasm by `pnpm build:wasm` (`sandbox/shared/rust/pkg`); every sandbox runs the -//! palette on it with `?engine=rust` (`--engine=rust` in the terminal). - -pub mod dialog; -pub mod palette; - -#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))] -pub use exports::typescript; - -#[cfg(feature = "wasm")] -mod exports { - use dunky_core::{Config, Machine}; - use wasm_bindgen::prelude::*; - - use crate::{dialog, palette}; - - // One config per machine type, shared by every instance (the shape a real app has). - thread_local! { - static PALETTE: Config = palette::config(Vec::new()); - static DIALOG: Config = dialog::config(0); - } - - dunky_wasm::export_machine! { - /// The command palette. - pub struct PaletteMachine(palette::Palette); - new(commands: JsValue) { - // Malformed commands throw: the host maps result indices onto its own array. - dunky_wasm::from_js::>(commands) - .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) - } - computed { - palette::RESULTS => Vec, - palette::ACTIVE_ID => Option, - } - // Only the JS side's `results` mapping reads it: not part of the machine's types. - internal { - palette::RESULT_INDICES => Vec, - } - } - - dunky_wasm::export_machine! { - /// A dialog whose `closing` phase lasts `exitMs`. - pub struct DialogMachine(dialog::Dialog); - new(exit_ms: u32) { - DIALOG.with(|c| Machine::with_context(c, dialog::DialogCtx { exit_ms, open_count: 0 })) - } - computed {} - } - - /// The classes' TS types (`examples/typescript.rs`, run by `pnpm build:wasm`). - #[cfg(not(target_arch = "wasm32"))] - pub fn typescript() -> String { - PALETTE.with(PaletteMachine::typescript) + &DIALOG.with(DialogMachine::typescript) - } -} diff --git a/sandbox/shared/src/index.ts b/sandbox/shared/src/index.ts index ea943d8..2ae22e0 100644 --- a/sandbox/shared/src/index.ts +++ b/sandbox/shared/src/index.ts @@ -9,5 +9,4 @@ export type { CommandPaletteComputed, CommandPaletteState, CommandPaletteProps, - CommandPaletteSource, } from './types' diff --git a/sandbox/shared/src/rust-node.ts b/sandbox/shared/src/rust-node.ts deleted file mode 100644 index 74b8006..0000000 --- a/sandbox/shared/src/rust-node.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readFileSync } from 'node:fs' -import { loadRustSync } from './rust' - -/** Load the sandbox wasm in Node or Bun from the built package. */ -export function loadRustNode(): void { - loadRustSync(readFileSync(new URL('../rust/pkg/dunky_sandbox_bg.wasm', import.meta.url))) -} - -export * from './rust' diff --git a/sandbox/shared/src/rust.ts b/sandbox/shared/src/rust.ts deleted file mode 100644 index e088e5f..0000000 --- a/sandbox/shared/src/rust.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * The sandbox machines written in Rust (sandbox/shared/rust), wrapped with `fromWasm`. - * Build the wasm first: `pnpm build:wasm`. Load it once — `await loadRust()` in a - * browser, `loadRustSync(bytes)` (or `./rust-node`) in Node and Bun — then build - * machines with the factories below. Their types come from the Rust types: the build - * generates them onto each class, and `fromWasm` reads them. - */ -import { fromWasm, type TypesOf } from '@dunky.dev/state-machine' -import init, { DialogMachine, initSync, PaletteMachine } from '../rust/pkg/dunky_sandbox.js' -import type { CommandPaletteMachine } from './machine' -import type { - Command, - CommandPaletteComputed, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteState, -} from './types' - -/** Load the wasm in a browser (fetches `dunky_sandbox_bg.wasm` next to the glue). */ -export async function loadRust(input?: Parameters[0]): Promise { - await init(input) -} - -/** Load the wasm synchronously from its bytes (Node, Bun, tests, workers). */ -export function loadRustSync(bytes: BufferSource): void { - initSync({ module: bytes }) -} - -/** The command palette. `results` crosses the boundary as indices and maps back onto - * the caller's own command objects: Rust owns the ids, rich values stay in JS. */ -export function createRustPalette(commands: Command[]): CommandPaletteMachine { - return fromWasm(new PaletteMachine(commands), { - computed: { - results: { - from: 'resultIndices', - map: raw => Array.from(raw as ArrayLike, i => commands[i]!), - }, - }, - }) -} - -/** A dialog whose `closing` phase lasts `exitMs` (an `after` timer on the host clock). */ -export function createRustDialog(exitMs: number) { - return fromWasm(new DialogMachine(exitMs)) -} - -export type DialogState = TypesOf['state'] -export type DialogContext = TypesOf['context'] -export type DialogEvent = TypesOf['event'] - -// The Rust palette's generated types must be the TS palette's, exactly: `pnpm typecheck` -// fails here as soon as one side changes without the other. -type Same = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Expect = T -type RustPalette = TypesOf -export type RustPaletteMatchesTs = [ - Expect>, - Expect>, - Expect>, - Expect>, -] diff --git a/sandbox/shared/src/types.ts b/sandbox/shared/src/types.ts index d833165..46828ed 100644 --- a/sandbox/shared/src/types.ts +++ b/sandbox/shared/src/types.ts @@ -1,5 +1,3 @@ -import type { MachineSource } from '@dunky.dev/state-machine' - // ============================================================================= // cmdk — shared types // @@ -61,14 +59,3 @@ export interface CommandPaletteProps { /** Fired whenever the palette opens or closes. */ onOpenChange?: (open: boolean) => void } - -/** Where a renderer gets its palette machine: the TS config, or a ready machine (e.g. the - * Rust palette via wasm). Both seed from the same props and speak the same events. */ -export type CommandPaletteSource = ( - props: CommandPaletteProps, -) => MachineSource< - CommandPaletteState, - CommandPaletteContext, - CommandPaletteEvent, - CommandPaletteComputed -> diff --git a/sandbox/shared/tests/rust.test.ts b/sandbox/shared/tests/rust.test.ts deleted file mode 100644 index 72c44e4..0000000 --- a/sandbox/shared/tests/rust.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * The palette written in Rust (through wasm + `fromWasm`) must behave exactly like the - * TS palette for the same event sequence — state, context, computed, and the - * notifications. Needs the wasm build: `pnpm build:wasm`. - */ -import { beforeAll, describe, expect, it, vi } from 'vitest' -import { machine } from '@dunky.dev/state-machine' -import { commandPaletteMachineConfig, DEMO_COMMANDS, type CommandPaletteEvent } from '../src' -import { createRustDialog, createRustPalette, loadRustNode } from '../src/rust-node' - -beforeAll(() => loadRustNode()) - -const script: CommandPaletteEvent[] = [ - { type: 'open' }, - { type: 'move', to: 'down' }, - { type: 'move', to: 'down' }, - { type: 'query.set', query: 'go' }, - { type: 'move', to: 'up' }, - { type: 'highlight', index: 99 }, - { type: 'query.set', query: 'zzz' }, - { type: 'move', to: 'down' }, - { type: 'execute' }, - { type: 'query.set', query: 'theme' }, - { type: 'move', to: 'last' }, - { type: 'execute' }, - { type: 'open' }, - { type: 'move', to: 'first' }, - { type: 'execute' }, - { type: 'close' }, -] - -const snapshot = (m: ReturnType) => ({ - state: m.state, - query: m.context.query, - activeIndex: m.context.activeIndex, - lastExecuted: m.context.lastExecuted, - results: m.computed.results.map(c => c.id), - activeId: m.computed.activeId, -}) - -describe('Rust palette', () => { - it('matches the TS palette step by step, notification for notification', () => { - const ts = machine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) - const rs = createRustPalette(DEMO_COMMANDS) - const seen = { ts: [] as unknown[], rs: [] as unknown[] } - ts.subscribe(() => seen.ts.push(snapshot(ts))) - rs.subscribe(() => seen.rs.push(snapshot(rs))) - ts.start() - rs.start() - for (const event of script) { - ts.send(event) - rs.send(event) - expect(snapshot(rs)).toEqual(snapshot(ts)) - } - expect(seen.rs).toEqual(seen.ts) - }) - - it('maps result indices back onto the caller’s own command objects', () => { - const rs = createRustPalette(DEMO_COMMANDS) - rs.send({ type: 'open' }) - expect(rs.computed.results[0]).toBe(DEMO_COMMANDS[0]) - }) - - it('throws on malformed props instead of guessing', () => { - expect(() => - createRustPalette([{ id: 1 } as unknown as (typeof DEMO_COMMANDS)[number]]), - ).toThrow('[machine] bad argument: invalid type') - }) -}) - -describe('Rust dialog', () => { - it('runs the `after` timer on the host clock, and cancels it when the state is left', () => { - vi.useFakeTimers() - try { - const d = createRustDialog(150) - d.start() - d.send({ type: 'open' }) - expect(d.hasTag('visible')).toBe(true) - d.send({ type: 'close' }) - vi.advanceTimersByTime(149) - expect(d.state).toBe('closing') - vi.advanceTimersByTime(1) - expect(d.state).toBe('closed') - expect(d.context.openCount).toBe(1) - - d.send({ type: 'open' }) - d.send({ type: 'close' }) - d.send({ type: 'open' }) // back to open before the exit delay ends - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 21c879a..3d9110c 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -1,11 +1,8 @@ import { createSignal } from 'solid-js' -import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -/** The language the palette machine is written in. Both run on the Rust engine. */ -export type Language = 'ts' | 'rust' - -export function App(props: { language: Language; source: CommandPaletteSource }) { +export function App() { const [last, setLast] = createSignal('—') return ( @@ -13,7 +10,6 @@ export function App(props: { language: Language; source: CommandPaletteSource })

⌘K Command Palette


{ setLast(c.label) @@ -29,12 +25,6 @@ export function App(props: { language: Language; source: CommandPaletteSource })

Last selected: {last()}

-

- Machine written in {props.language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} - - Switch to {props.language === 'rust' ? 'TypeScript' : 'Rust'} - -

) } diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx index 12a815e..74a5b1b 100644 --- a/sandbox/solid/src/command-palette.tsx +++ b/sandbox/solid/src/command-palette.tsx @@ -1,9 +1,9 @@ import { createEffect, For, Show } from 'solid-js' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' import { + commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, - type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -27,10 +27,13 @@ const cmdkShortcut: ComponentEffect // machine and `normalize` maps the logical bindings to DOM props. The // component is just markup; the look lives in the stylesheet shared with the // React app. -// `source` is the TS config or the Rust machine (see main.tsx), read once: the machine -// is built once, like any initial-only prop. -export function CommandPalette(props: CommandPaletteProps & { source: CommandPaletteSource }) { - const { api } = useMachine(props.source, connectCommandPalette, [cmdkShortcut], props) +export function CommandPalette(props: CommandPaletteProps) { + const { api } = useMachine( + commandPaletteMachineConfig, + connectCommandPalette, + [cmdkShortcut], + props, + ) let inputEl: HTMLInputElement | undefined diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index c896744..ed52a73 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -1,6 +1,5 @@ import { render } from '@solidjs/web' -import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' -import { App, type Language } from './app' +import { App } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -8,20 +7,4 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -// `?machine=rust` runs the palette written in Rust (sandbox/shared/rust → wasm) instead -// of the one written in TS. Build the wasm first: `pnpm build:wasm`. -const language: Language = - new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' - -async function boot(target: HTMLElement) { - let source: CommandPaletteSource = commandPaletteMachineConfig - if (language === 'rust') { - // Imported only for the Rust machine, so the TS page loads no second wasm module. - const rust = await import('@sandbox/cmdk-core/rust') - await rust.loadRust() - source = props => rust.createRustPalette(props.commands) - } - render(() => , target) -} - -void boot(root) +render(() => , root) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh deleted file mode 100755 index 357300a..0000000 --- a/scripts/build-wasm.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# Build the wasm modules: -# - packages/core/wasm: the Rust engine + the runtime for TS-authored machines, which -# @dunky.dev/state-machine loads synchronously from the inlined bytes (wasm/bytes.js). -# - sandbox/shared/rust/pkg: the sandbox machines written in Rust (palette, dialog). -# - benchmark/rust/pkg: the benchmark machines written in Rust. -# Needs: rustup target wasm32-unknown-unknown, wasm-bindgen-cli matching the crate -# version (see Cargo.toml), and optionally wasm-opt (binaryen) for the size pass. -set -euo pipefail -cd "$(dirname "$0")/.." - -# The features rustc enables by default for wasm32 (Rust >= 1.82); binaryen must accept them. -WASM_OPT_FLAGS=(-Oz --strip-debug --strip-producers - --enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int - --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue) - -# build [cargo args...] -build() { - local crate=$1 lib=$2 out=$3 - shift 3 - cargo build -p "$crate" --target wasm32-unknown-unknown --profile wasm "$@" - # --target web: one ES module for browsers (init) and Node/Bun/tests (initSync). - # --weak-refs: instances are freed by a FinalizationRegistry. - # --reference-types: JS values cross as externrefs, so passing one costs no JS call. - wasm-bindgen --target web --weak-refs --reference-types --out-dir "$out" "target/wasm32-unknown-unknown/wasm/$lib.wasm" - if command -v wasm-opt >/dev/null 2>&1; then - wasm-opt "${WASM_OPT_FLAGS[@]}" "$out/${lib}_bg.wasm" -o "$out/${lib}_bg.wasm" - else - echo "wasm-opt not found: skipping the size pass (brew install binaryen)" >&2 - fi - local raw gz - raw=$(wc -c < "$out/${lib}_bg.wasm" | tr -d ' ') - gz=$(gzip -9c "$out/${lib}_bg.wasm" | wc -c | tr -d ' ') - echo "$crate: ${raw} B raw, ${gz} B gzip" -} - -build dunky-core-wasm dunky_core_wasm packages/core/wasm -# Inline the bytes so the package starts synchronously (no `await init()`). -{ - printf '// Generated by scripts/build-wasm.sh — the wasm of crates/core-wasm, base64.\n' - printf 'export const WASM = "%s"\n' "$(base64 < packages/core/wasm/dunky_core_wasm_bg.wasm | tr -d '\n')" -} > packages/core/wasm/bytes.js -printf 'export declare const WASM: string\n' > packages/core/wasm/bytes.d.ts - -# types <.d.ts> [cargo args...] — a Rust machine's TS types come from its Rust -# types: the crate's `typescript` example prints them for each exported class, and they -# join the class wasm-bindgen declared, where fromWasm reads them. -types() { - local crate=$1 dts=$2 - shift 2 - cargo run -q -p "$crate" --example typescript "$@" >> "$dts" -} - -build dunky-sandbox dunky_sandbox sandbox/shared/rust/pkg --features wasm -types dunky-sandbox sandbox/shared/rust/pkg/dunky_sandbox.d.ts --features wasm -build dunky-benchmark dunky_benchmark benchmark/rust/pkg -types dunky-benchmark benchmark/rust/pkg/dunky_benchmark.d.ts diff --git a/tsconfig.build.json b/tsconfig.build.json index ec110c6..ba5a59a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,7 +10,6 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, - { "path": "./benchmark" }, - { "path": "./sandbox/shared" } + { "path": "./benchmark" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index 5940b5e..117b2dc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ name: 'default', globals: false, environment: 'node', - include: ['packages/**/tests/**/*.test.{ts,tsx}', 'sandbox/shared/tests/**/*.test.ts'], + include: ['packages/**/tests/**/*.test.{ts,tsx}'], exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], }, }, diff --git a/website/src/content/docs/benchmark.mdx b/website/src/content/docs/benchmark.mdx index b2be4b8..325c2e3 100644 --- a/website/src/content/docs/benchmark.mdx +++ b/website/src/content/docs/benchmark.mdx @@ -1,6 +1,6 @@ --- title: Benchmark -description: Throughput and memory vs. XState and Zag, and machines written in TS vs Rust. +description: Throughput and memory vs. XState and Zag. head: - tag: style content: | @@ -29,23 +29,23 @@ and run anywhere. The speed is what that model gives you for free. ## How these numbers were measured -These were run on a **MacBook Pro (M5 Pro, 64 GB)** with Node 24. The engine is Rust, compiled to WebAssembly: the Dunky columns run machines written in TypeScript on it, and [a section below](#machines-written-in-ts-vs-rust) runs the same machines written in Rust. Your absolute numbers will differ, so **[run it yourself](https://github.com/dunky-dev/state-machine/blob/main/benchmark/README.md#running)** and see. +These were run on a **MacBook Pro (M1, 32 GB)**. Your absolute numbers will differ, so **[run it yourself](https://github.com/dunky-dev/state-machine/blob/main/benchmark/README.md#running)** and see. **Why a column is sometimes blank.** The three engines don't run the same way, so not every test fits all three. The cell is marked, the same way everywhere: - **`n/a ᵃ`**: _async._ Zag's `send` is microtask-batched, so it can't run in a synchronous ops/sec or `flushSync` loop, only where it runs synchronously (construction, memory, React rendering). - **`n/a ᶠ`**: _no equivalent feature._ The engine has no first-class primitive for that scenario (e.g. XState has no lazy/memoized `computed`), so there's nothing comparable to time. -**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 5×–34× gaps are the point. +**Reading the ops/sec tables:** higher is better, and a gap between two rows is only real if it clears both rows' run-to-run noise (`±rme`). A 1.0× / 1.2× difference is a tie; the 7×–31× gaps are the point. ## Overview -| | Dunky | XState | Zag | -| ------------------------- | --------------: | -----: | -----: | -| Event throughput | **9.9 M ops/s** | 1.6 M | n/a ᵃ | -| Memory, 2-field context | **3.5 KB** | 3.6 KB | 8.9 KB | -| Memory, 64-field context | **4.0 KB** | 4.1 KB | 134 KB | -| Re-render wall, 1000 rows | **2.4 ms** | 4.5 ms | n/a ᵃ | +| | Dunky | XState | Zag | +| ------------------------- | ---------------: | -----: | -----: | +| Event throughput | **11.6 M ops/s** | 1.6 M | n/a ᵃ | +| Memory, 2-field context | **3.9 KB** | 3.6 KB | 8.9 KB | +| Memory, 64-field context | **4.4 KB** | 4.1 KB | 134 KB | +| Re-render wall, 1000 rows | **2.4 ms** | 4.3 ms | n/a ᵃ | ᵃ Zag's `send` is microtask-batched; can't run in a synchronous ops/sec loop. @@ -53,9 +53,9 @@ These were run on a **MacBook Pro (M5 Pro, 64 GB)** with Node 24. The engine is A single machine, one event, tight loop: -| | Dunky | XState | Zag | -| ------- | --------: | -----: | ----: | -| ops/sec | **9.9 M** | 1.6 M | n/a ᵃ | +| | Dunky | XState | Zag | +| ------- | ---------: | -----: | ----: | +| ops/sec | **11.6 M** | 1.6 M | n/a ᵃ | XState allocates a new immutable snapshot on every transition. Dunky mutates context in place, so a transition allocates nothing. @@ -65,8 +65,8 @@ Change a field no observer has selected. The dedup layer re-evaluates and value- | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 1 000 | **4.8 M** | 885.7 K | -| 5 000 | **3.9 M** | 842.4 K | +| 1 000 | **5.4 M** | 987.9 K | +| 5 000 | **4.4 M** | 844.1 K | XState's `actor.subscribe` is coarse: it fires on every snapshot change. To match Dunky's behavior you'd add a differ in the listener, which is what the `xstate` column already does, for a fair comparison. @@ -77,9 +77,9 @@ affected observer" cycles complete per second (higher is better): | Observers | Dunky (ops/s) | XState (ops/s) | | --------- | ------------: | -------------: | -| 100 | 659.3 K | 445.2 K | -| 1 000 | 53.5 K | 52.4 K | -| 5 000 | **19.1 K** | 1.3 K | +| 100 | 658.5 K | 437.8 K | +| 1 000 | 56.8 K | 49.2 K | +| 5 000 | **15.5 K** | 1.3 K | Roughly par at small N. The gap widens with N because a coarse subscribe re-runs every listener on each change, while a fine-grained selection wakes only the affected one. @@ -89,8 +89,8 @@ The whole point of the plain-object model: memory grows with your data, not with | Context width | Dunky | XState | Zag | | ------------- | -----: | -----: | -----: | -| 2 fields | 3.5 KB | 3.6 KB | 8.9 KB | -| 64 fields | 4.0 KB | 4.1 KB | 134 KB | +| 2 fields | 3.9 KB | 3.6 KB | 8.9 KB | +| 64 fields | 4.4 KB | 4.1 KB | 134 KB | Going 2 → 64 fields costs Dunky **~0.5 KB/machine**. Zag allocates one reactive cell per field, so a 64-field context grows to **134 KB/machine**. @@ -100,23 +100,9 @@ Spin-up cost per machine: | | Dunky | XState | Zag | | ------------ | ----: | -------: | ---: | -| µs / machine | 1.36 | **1.07** | 5.02 | +| µs / machine | 1.58 | **1.35** | 5.06 | -XState cold-starts ~1.3× faster; Zag is ~3.7× slower than Dunky. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). - -## Machines written in TS vs Rust - -Every machine runs on the Rust engine, but you can write it in TypeScript or in Rust (wrapped with `fromWasm`). Each row runs the **same machine** both ways: - -| Scenario (ops/s) | TS machine | Rust machine | -| ---------------------------------- | ---------: | -----------: | -| One event | **10.7 M** | 8.5 M | -| State churn (exit + entry actions) | **10.4 M** | 8.0 M | -| Guard fallthrough, 8 candidates | 8.9 M | **9.7 M** | -| Command palette, 1,000 commands | **13.1 K** | 8.2 K | -| Command palette, 10,000 commands | **1.3 K** | 819 | - -In JavaScript, a TS machine is usually faster (~1.3× on events): its context already lives in JS, while a Rust machine pushes each change back across the boundary into a JS mirror of its context. A guard walk is the exception: in a Rust machine it never leaves wasm. In React both render alike: a 1000-row list re-renders in about 2.4 ms either way. Rust machines are for reuse: the same machine runs in Rust programs and over JSI, where there is no boundary to cross. +XState cold-starts ~1.2× faster; Zag is ~3.2× slower than both. A one-time cost paid at `start()`; see [the trade-off](#the-trade-off). ## Where this matters From 000ff4a23053606448a3d1c74eef40d9cf9ae287 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 12:35:06 +0200 Subject: [PATCH 23/28] feat: Rust machines in TypeScript, through wasm 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 --- .changeset/state-machine-wasm.md | 22 ++ .changeset/use-machine-accepts-machine.md | 29 ++ .github/actions/wasm-setup/action.yml | 36 ++ .github/workflows/ci-checks.yml | 4 + .gitignore | 2 + .oxlintrc.json | 2 +- AGENTS.md | 14 +- ARCHITECTURE.md | 8 + CONTRIBUTING.md | 5 + Cargo.lock | 140 ++++++++ Cargo.toml | 16 +- TODO.md | 35 ++ crates/core/src/lib.rs | 6 +- crates/core/src/ts.rs | 168 ++++++++++ crates/core/tests/derive.rs | 51 ++- crates/macros/Cargo.toml | 2 +- crates/macros/src/lib.rs | 67 +++- crates/macros/src/ts.rs | 298 +++++++++++++++++ crates/sandbox/Cargo.toml | 16 +- crates/sandbox/examples/typescript.rs | 6 + crates/sandbox/src/lib.rs | 54 ++- crates/sandbox/src/palette.rs | 8 +- crates/uniffi/src/lib.rs | 5 +- crates/wasm/Cargo.toml | 14 + crates/wasm/README.md | 52 +++ crates/wasm/SPEC.md | 125 +++++++ crates/wasm/src/bridge.rs | 188 +++++++++++ crates/wasm/src/lib.rs | 382 ++++++++++++++++++++++ package.json | 1 + packages/core/src/index.ts | 6 +- packages/core/src/machine.ts | 26 ++ packages/react/src/use-machine.ts | 23 +- packages/react/tests/use-machine.test.tsx | 19 ++ packages/solid/src/use-machine.ts | 23 +- packages/solid/tests/use-machine.test.tsx | 19 ++ packages/wasm/LICENSE | 21 ++ packages/wasm/README.md | 41 +++ packages/wasm/package.json | 40 +++ packages/wasm/src/from-wasm.ts | 345 +++++++++++++++++++ packages/wasm/src/index.ts | 9 + packages/wasm/tests/from-wasm.test.ts | 113 +++++++ packages/wasm/tsconfig.json | 5 + pnpm-lock.yaml | 18 + sandbox/README.md | 19 +- sandbox/opentui/package.json | 4 +- sandbox/opentui/src/app.tsx | 10 +- sandbox/opentui/src/command-palette.tsx | 10 +- sandbox/opentui/src/index.tsx | 17 +- sandbox/react/package.json | 1 + sandbox/react/src/app.tsx | 14 +- sandbox/react/src/command-palette.tsx | 15 +- sandbox/react/src/main.tsx | 29 +- sandbox/react/vite.config.ts | 1 + sandbox/shared/package.json | 7 +- sandbox/shared/src/index.ts | 1 + sandbox/shared/src/rust-node.ts | 11 + sandbox/shared/src/rust.ts | 65 ++++ sandbox/shared/src/types.ts | 13 + sandbox/shared/tests/rust.test.ts | 94 ++++++ sandbox/solid/package.json | 1 + sandbox/solid/src/app.tsx | 14 +- sandbox/solid/src/command-palette.tsx | 13 +- sandbox/solid/src/main.tsx | 21 +- sandbox/solid/vite.config.ts | 1 + scripts/build-wasm.sh | 43 +++ tsconfig.build.json | 4 +- tsconfig.json | 1 + tsdown.config.ts | 1 + vitest.config.ts | 2 +- 69 files changed, 2797 insertions(+), 79 deletions(-) create mode 100644 .changeset/state-machine-wasm.md create mode 100644 .changeset/use-machine-accepts-machine.md create mode 100644 .github/actions/wasm-setup/action.yml create mode 100644 TODO.md create mode 100644 crates/core/src/ts.rs create mode 100644 crates/macros/src/ts.rs create mode 100644 crates/sandbox/examples/typescript.rs create mode 100644 crates/wasm/Cargo.toml create mode 100644 crates/wasm/README.md create mode 100644 crates/wasm/SPEC.md create mode 100644 crates/wasm/src/bridge.rs create mode 100644 crates/wasm/src/lib.rs create mode 100644 packages/wasm/LICENSE create mode 100644 packages/wasm/README.md create mode 100644 packages/wasm/package.json create mode 100644 packages/wasm/src/from-wasm.ts create mode 100644 packages/wasm/src/index.ts create mode 100644 packages/wasm/tests/from-wasm.test.ts create mode 100644 packages/wasm/tsconfig.json create mode 100644 sandbox/shared/src/rust-node.ts create mode 100644 sandbox/shared/src/rust.ts create mode 100644 sandbox/shared/tests/rust.test.ts create mode 100755 scripts/build-wasm.sh diff --git a/.changeset/state-machine-wasm.md b/.changeset/state-machine-wasm.md new file mode 100644 index 0000000..43b6617 --- /dev/null +++ b/.changeset/state-machine-wasm.md @@ -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. diff --git a/.changeset/use-machine-accepts-machine.md b/.changeset/use-machine-accepts-machine.md new file mode 100644 index 0000000..6b0c40e --- /dev/null +++ b/.changeset/use-machine-accepts-machine.md @@ -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. diff --git a/.github/actions/wasm-setup/action.yml b/.github/actions/wasm-setup/action.yml new file mode 100644 index 0000000..364ebd2 --- /dev/null +++ b/.github/actions/wasm-setup/action.yml @@ -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 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 65ac498..30fe9da 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -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: @@ -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: diff --git a/.gitignore b/.gitignore index 62c39b7..2febef2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ dist # 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 diff --git a/.oxlintrc.json b/.oxlintrc.json index f1dd1ef..07f4324 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,5 +45,5 @@ } }, "globals": {}, - "ignorePatterns": ["**/dist/**", "website/src/snippets/**"] + "ignorePatterns": ["**/dist/**", "website/src/snippets/**", "crates/sandbox/pkg/**"] } diff --git a/AGENTS.md b/AGENTS.md index 5955dcc..85195bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +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 | -| Crates | `crates/**` | The Rust engine (`core`, `macros`), the sandbox machines in Rust, the RN binding (`uniffi`) | +| 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 | +| 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2f0ddb9..e43cfd9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,6 +101,8 @@ Zag, whose machines read props directly.) | `packages//` | 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) | @@ -162,6 +164,12 @@ 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2a5e2a..2439d17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,11 @@ The Rust engine (`crates/`) is a plain Cargo workspace; run cargo at the repo ro | `cargo lint` | `clippy`, warnings as errors (`.cargo/config.toml`) | | `cargo fmt` / `--check` | `rustfmt` | +`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 diff --git a/Cargo.lock b/Cargo.lock index 608bcc4..94f4996 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "dunky-core" version = "0.1.0" @@ -25,8 +37,45 @@ name = "dunky-sandbox" version = "0.1.0" dependencies = [ "dunky-core", + "dunky-wasm", "serde", "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "dunky-wasm" +version = "0.1.0" +dependencies = [ + "dunky-core", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] @@ -35,12 +84,35 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -59,6 +131,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -69,6 +147,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -102,6 +191,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "2.0.119" @@ -130,6 +225,51 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index dc5b42c..06350bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ -# The Rust engine: its own implementation of packages/core/SPEC.md, next to the TS one. +# The Rust engine: its own implementation of packages/core/SPEC.md, next to the TS one, +# and its JS binding (crates/wasm). # Members are listed explicitly so a half-built crate never breaks # `cargo test --workspace` for the others. [workspace] resolver = "3" -members = ["crates/core", "crates/macros", "crates/sandbox"] +members = ["crates/core", "crates/macros", "crates/wasm", "crates/sandbox"] # Built by its own pipeline (uniffi-bindgen-react-native) for iOS/Android. exclude = ["crates/uniffi"] @@ -17,12 +18,23 @@ rust-version = "1.85" [workspace.dependencies] dunky-core = { path = "crates/core" } dunky-macros = { path = "crates/macros" } +dunky-wasm = { path = "crates/wasm" } dunky-sandbox = { path = "crates/sandbox" } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Must match the installed wasm-bindgen CLI exactly (wasm-bindgen --version). +wasm-bindgen = "=0.2.129" +js-sys = "0.3" +serde-wasm-bindgen = "0.6" [profile.release] opt-level = 3 lto = true codegen-units = 1 panic = "abort" + +# Size-tuned profile for the shipped .wasm. +[profile.wasm] +inherits = "release" +opt-level = "z" +strip = true diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..36de361 --- /dev/null +++ b/TODO.md @@ -0,0 +1,35 @@ +# TODO + +Parked work, with the decisions already made. The earlier shape, where the TS engine +ran on the Rust engine, is on the local branch `rust-engine-under-ts` (head `4e492a4`). + +## 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. + +## Rust machines in JS (wasm) — done, notes + +- `@dunky.dev/state-machine-wasm` (`packages/wasm`) holds `fromWasm`; the TS engine + stays pure TS. `crates/wasm` is Dunky's JS binding (`export_machine!`), Dunky-specific, + in this repo because its protocol and the facade change together. +- A user's crate depends on `dunky-core` + `dunky-wasm` + `wasm-bindgen` and builds its + own `.wasm`; each module carries its own copy of the engine. +- The binding hears changes through the engine's own `subscribe()` / `take_changes()` + and runs timers from `take_commands()`: the engine has no hook for JS. +- Types come from Rust (`TsType`, `typescript(&config)`, the `__types` member); + `internal { }` computed values stay out of the types. A type test pins the Rust + palette's types to the TS palette's. +- Measured on the earlier shape: in JS a Rust machine handles ~1.3× fewer events than + its TS twin (8.5 M vs 10.7 M events/s, one event); a guard walk is ~1.1× faster in Rust. + +## Later + +- [ ] Review what else to bring back from `rust-engine-under-ts` on the TS side. +- [ ] Benchmarks split by engine: the TS suite moves to `benchmark/ts`, and a Rust suite + lives in `benchmark/rust` (the Rust engine measured natively, on its own). diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 6e5a4b9..96e828c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -28,6 +28,7 @@ mod selection; mod store; mod timers; mod traits; +mod ts; pub mod testing; @@ -47,9 +48,10 @@ pub use timers::{Command, TimerId}; pub use traits::{Context, EventEnum, Field, StateEnum, Types}; #[cfg(feature = "serde")] pub use traits::{DeserializeEvent, SerializeFields}; +pub use ts::TsType; #[cfg(feature = "derive")] -pub use dunky_macros::{Context, Event, State}; +pub use dunky_macros::{Context, Event, State, TsType}; /// The `machine.init` marker name: what a boot effect or a data-reaction sees as its /// event type (`ActionParams::event()` returns `None` for it). @@ -59,4 +61,6 @@ pub const MACHINE_INIT: &str = "machine.init"; pub mod __private { #[cfg(feature = "serde")] pub use serde; + + pub use crate::ts::{key as ts_key, literal as ts_literal, object as ts_object}; } diff --git a/crates/core/src/ts.rs b/crates/core/src/ts.rs new file mode 100644 index 0000000..b80131f --- /dev/null +++ b/crates/core/src/ts.rs @@ -0,0 +1,168 @@ +//! TypeScript types of the values a machine shows JS, so a binding can generate a Rust +//! machine's TS types from its Rust ones. The mapping follows how `dunky-wasm` moves +//! values through serde: `None` is `null`, sequences are arrays, maps are `Map`s, and +//! every number is a `number`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +/// The TypeScript type of a value as JS sees it. `#[derive(TsType)]` implements it for +/// your own data types; `#[derive(State, Event, Context)]` implement it for the machine's. +pub trait TsType { + /// The type, written inline: `string`, `{ id: string; hint?: string }`, `number[]`. + fn ts() -> String; +} + +macro_rules! literal { + ($ts:literal: $($ty:ty),*) => { + $(impl TsType for $ty { + fn ts() -> String { + $ts.into() + } + })* + }; +} + +literal!("number": u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64); +literal!("string": String, str, char); +literal!("boolean": bool); +literal!("null": ()); + +macro_rules! same_as_inner { + ($($ty:ident),*) => { + $(impl TsType for $ty { + fn ts() -> String { + T::ts() + } + })* + }; +} + +same_as_inner!(Box, Rc, Arc); + +impl TsType for &T { + fn ts() -> String { + T::ts() + } +} + +impl TsType for Option { + fn ts() -> String { + format!("{} | null", T::ts()) + } +} + +macro_rules! sequence { + ($($ty:ident),*) => { + $(impl TsType for $ty { + fn ts() -> String { + array(&T::ts()) + } + })* + }; +} + +sequence!(Vec, VecDeque, HashSet, BTreeSet); + +impl TsType for [T] { + fn ts() -> String { + array(&T::ts()) + } +} + +impl TsType for [T; N] { + fn ts() -> String { + array(&T::ts()) + } +} + +impl TsType for HashMap { + fn ts() -> String { + format!("Map<{}, {}>", K::ts(), V::ts()) + } +} + +impl TsType for BTreeMap { + fn ts() -> String { + format!("Map<{}, {}>", K::ts(), V::ts()) + } +} + +macro_rules! tuple { + ($($name:ident),+) => { + impl<$($name: TsType),+> TsType for ($($name,)+) { + fn ts() -> String { + let items: &[String] = &[$($name::ts()),+]; + format!("[{}]", items.join(", ")) + } + } + }; +} + +tuple!(A); +tuple!(A, B); +tuple!(A, B, C); +tuple!(A, B, C, D); + +/// `T[]`, or `Array` when `T` is a union or an object. +fn array(item: &str) -> String { + if item.contains(' ') { + format!("Array<{item}>") + } else { + format!("{item}[]") + } +} + +/// An object key: as is when it is an identifier, else quoted. +#[doc(hidden)] +pub fn key(name: &str) -> String { + let mut chars = name.chars(); + let identifier = chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$'); + if identifier { + name.to_owned() + } else { + literal(name) + } +} + +/// A string literal type: `'name'`. +#[doc(hidden)] +pub fn literal(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + +/// An object type from its members (`key: type` or `key?: type`). +#[doc(hidden)] +pub fn object(members: &[String]) -> String { + if members.is_empty() { + "{}".into() + } else { + format!("{{ {} }}", members.join("; ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_values_the_way_they_reach_js() { + assert_eq!(>::ts(), "string | null"); + assert_eq!(>::ts(), "number[]"); + assert_eq!(>>::ts(), "Array"); + assert_eq!(>::ts(), "Map"); + assert_eq!(<(u8, String)>::ts(), "[number, string]"); + } + + #[test] + fn quotes_keys_and_literals_only_when_needed() { + assert_eq!(key("activeId"), "activeId"); + assert_eq!(key("query.set"), "'query.set'"); + assert_eq!(literal("it's"), "'it\\'s'"); + assert_eq!(object(&[]), "{}"); + } +} diff --git a/crates/core/tests/derive.rs b/crates/core/tests/derive.rs index f5e83fc..753c90c 100644 --- a/crates/core/tests/derive.rs +++ b/crates/core/tests/derive.rs @@ -1,8 +1,8 @@ //! Rust-only: the derive macros name events exactly like serde, so the JS side can send //! `{ type, ... }` objects that both decoders (serde and `DeserializeEvent`) accept. -use dunky_core::{DeserializeEvent, Event, EventEnum}; -use serde::Deserialize; +use dunky_core::{Context, DeserializeEvent, Event, EventEnum, State, TsType}; +use serde::{Deserialize, Serialize}; #[derive(Event, Clone, Debug, PartialEq, Deserialize)] #[event(deserialize)] @@ -39,3 +39,50 @@ fn payload_decoding_follows_the_variant_field_renames() { ); assert_eq!(event.unwrap(), Picker::ItemPick { item_id: 3 }); } + +// Only their TS types are read. +#[allow(dead_code)] +#[derive(TsType, Serialize, Clone, PartialEq, Debug)] +struct Item { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option, + note: Option, +} + +#[allow(dead_code)] +#[derive(TsType, Serialize, Clone, Copy, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +enum Direction { + Up, + Down, +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + Idle, + HalfOpen, +} + +#[derive(Context, Clone, PartialEq, Debug)] +#[context(serialize)] +struct Ctx { + items: Vec, + active_index: u32, + direction: Option, +} + +#[test] +fn ts_types_follow_the_js_names_and_values() { + assert_eq!(Mode::ts(), "'idle' | 'halfOpen'"); + assert_eq!( + Picker::ts(), + "{ type: 'query_set'; query: string } | { type: 'item_pick'; itemId: number } \ + | { type: 'picker.close' }" + ); + assert_eq!( + Ctx::ts(), + "{ items: Array<{ id: string; hint?: string; note: string | null }>; \ + activeIndex: number; direction: 'up' | 'down' | null }" + ); +} diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 3139212..3e0c26c 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dunky-macros" -description = "Derive macros for dunky-core: State, Event, Context." +description = "Derive macros for dunky-core: State, Event, Context, TsType." version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 08b42cf..6e2ea93 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -1,5 +1,7 @@ //! Derive macros for `dunky-core`. Use them through the re-exports in `dunky_core`. +mod ts; + use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -41,6 +43,18 @@ pub fn derive_context(input: TokenStream) -> TokenStream { .into() } +/// `#[derive(TsType)]` on a data type a machine shows JS (a context field, an event +/// payload, a computed value): its TypeScript type, from the serde attributes it crosses +/// with. Supports structs with named fields, newtypes, unit enums (string unions), and +/// internally tagged enums (`#[serde(tag = "...")]`). +#[proc_macro_derive(TsType)] +pub fn derive_ts(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + ts::expand(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + fn camel(ident: &Ident) -> String { let s = ident.to_string(); let s = s.strip_prefix("r#").unwrap_or(&s); @@ -99,7 +113,7 @@ fn name_attr(attrs: &[Attribute], attr: &str, serde_too: bool) -> syn::Result Option { +pub(crate) fn serde_case(variant: &str, rule: &str) -> Option { let snake = || { let mut out = String::new(); for (i, ch) in variant.char_indices() { @@ -191,8 +205,15 @@ fn expand_state(input: DeriveInput) -> syn::Result { variants.push(&v.ident); } let indices: Vec = (0..variants.len()).collect(); + let literals: Vec = names + .iter() + .map(|n| quote!(::dunky_core::__private::ts_literal(#n))) + .collect(); + let ts_impl = ts::impl_ts(&input, ts::union(&literals)); let (impl_g, ty_g, where_g) = input.generics.split_for_impl(); Ok(quote! { + #ts_impl + impl #impl_g ::dunky_core::StateEnum for #name #ty_g #where_g { const NAMES: &'static [&'static str] = &[#(#names),*]; fn index(self) -> usize { @@ -285,6 +306,28 @@ fn expand_event(input: DeriveInput) -> syn::Result { }); } let indices: Vec = (0..variants.len()).collect(); + let ts_impl = if deserialize { + let mut types = Vec::new(); + for (v, kind_name) in data.variants.iter().zip(&names) { + let fields_rule = + serde_str(&v.attrs, "rename_all")?.or_else(|| rename_all_fields.clone()); + let mut members = vec![ts::tag_member("type", kind_name)]; + match &v.fields { + Fields::Unit => {} + Fields::Named(fields) => members.extend(ts::members( + &fields.named, + fields_rule.as_deref(), + ts::Direction::FromJs, + )?), + // JS cannot send it: no field names to read. + Fields::Unnamed(_) => continue, + } + types.push(ts::object(&members)); + } + ts::impl_ts(&input, ts::union(&types)) + } else { + quote! {} + }; let deserialize_impl = if deserialize { quote! { impl ::dunky_core::DeserializeEvent for #name { @@ -334,6 +377,8 @@ fn expand_event(input: DeriveInput) -> syn::Result { } #deserialize_impl + + #ts_impl }) } @@ -389,6 +434,24 @@ fn expand_context(input: DeriveInput) -> syn::Result { let indices: Vec = (0..idents.len()).collect(); let bits: Vec = indices.iter().map(|i| 1u64 << i).collect(); + let ts_impl = if serialize { + let members: Vec = js_names + .iter() + .zip(&tys) + .map(|(js, ty)| { + quote! { + ::std::format!( + "{}: {}", + ::dunky_core::__private::ts_key(#js), + <#ty as ::dunky_core::TsType>::ts(), + ) + } + }) + .collect(); + ts::impl_ts(&input, ts::object(&members)) + } else { + quote! {} + }; let serialize_impl = if serialize { quote! { impl ::dunky_core::SerializeFields for #name { @@ -479,5 +542,7 @@ fn expand_context(input: DeriveInput) -> syn::Result { } #serialize_impl + + #ts_impl }) } diff --git a/crates/macros/src/ts.rs b/crates/macros/src/ts.rs new file mode 100644 index 0000000..72acf94 --- /dev/null +++ b/crates/macros/src/ts.rs @@ -0,0 +1,298 @@ +//! TypeScript types for the derives: `#[derive(TsType)]` on data types, and the +//! `TsType` impls `State`, `Event` and `Context` emit. Names follow the serde attributes +//! the value crosses with, so the TS type matches the JS value. + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, Ident, Lit, + PathArguments, Type, +}; + +use crate::serde_case; + +/// The serde attributes that shape a value's JS form. +#[derive(Default)] +pub(crate) struct Serde { + pub(crate) rename: Option, + pub(crate) rename_all: Option, + pub(crate) rename_all_fields: Option, + pub(crate) tag: Option, + skip: bool, + skip_serializing: bool, + skip_deserializing: bool, + skip_serializing_if: Option, + default: bool, + transparent: bool, +} + +pub(crate) fn serde_attrs(attrs: &[Attribute]) -> syn::Result { + let mut out = Serde::default(); + for a in attrs.iter().filter(|a| a.path().is_ident("serde")) { + a.parse_nested_meta(|meta| { + let name = meta + .path + .get_ident() + .map(Ident::to_string) + .unwrap_or_default(); + let value = if meta.input.peek(syn::Token![=]) { + match meta.value()?.parse::()? { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Some(s.value()), + _ => None, + } + } else if meta.input.peek(syn::token::Paren) { + if matches!(name.as_str(), "rename" | "rename_all") { + return Err( + meta.error("TsType: split serialize/deserialize names are not supported") + ); + } + let _content; + syn::parenthesized!(_content in meta.input); + None + } else { + None + }; + match name.as_str() { + "rename" => out.rename = value, + "rename_all" => out.rename_all = value, + "rename_all_fields" => out.rename_all_fields = value, + "tag" => out.tag = value, + "skip" => out.skip = true, + "skip_serializing" => out.skip_serializing = true, + "skip_deserializing" => out.skip_deserializing = true, + "skip_serializing_if" => out.skip_serializing_if = value, + "default" => out.default = true, + "transparent" => out.transparent = true, + "flatten" | "untagged" | "content" => { + return Err(meta.error(format!("TsType: `{name}` is not supported"))); + } + _ => {} + } + Ok(()) + })?; + } + Ok(out) +} + +/// Which way a value crosses: shown to JS (serialized) or sent from JS (deserialized). +#[derive(Clone, Copy)] +pub(crate) enum Direction { + ToJs, + FromJs, +} + +/// serde's `rename_all` rules for a (snake_case) field name, like serde applies them. +fn field_case(field: &str, rule: &str) -> Option { + let pascal = || { + field + .split('_') + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect::() + }; + Some(match rule { + "lowercase" | "snake_case" => field.to_owned(), + "UPPERCASE" | "SCREAMING_SNAKE_CASE" => field.to_ascii_uppercase(), + "PascalCase" => pascal(), + "camelCase" => { + let pascal = pascal(); + let mut chars = pascal.chars(); + chars + .next() + .map(|c| c.to_ascii_lowercase().to_string() + chars.as_str()) + .unwrap_or_default() + } + "kebab-case" => field.replace('_', "-"), + "SCREAMING-KEBAB-CASE" => field.replace('_', "-").to_ascii_uppercase(), + _ => return None, + }) +} + +fn unknown_rule(span: &impl quote::ToTokens, rule: &str) -> syn::Error { + syn::Error::new_spanned(span, format!("unknown rename_all rule \"{rule}\"")) +} + +/// The inner type of `Option`, read from the syntax. +fn option_inner(ty: &Type) -> Option<&Type> { + let Type::Path(path) = ty else { return None }; + let last = path.path.segments.last()?; + if last.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + match args.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + +/// The TS members of named fields (`key: type` or `key?: type`), as expressions that +/// build them. `rename_all` is the rule the fields' container applies. +pub(crate) fn members<'a>( + fields: impl IntoIterator, + rename_all: Option<&str>, + direction: Direction, +) -> syn::Result> { + let mut out = Vec::new(); + for f in fields { + let attrs = serde_attrs(&f.attrs)?; + let skipped = attrs.skip + || match direction { + Direction::ToJs => attrs.skip_serializing, + Direction::FromJs => attrs.skip_deserializing, + }; + if skipped { + continue; + } + let ident = f.ident.as_ref().expect("named field").to_string(); + let ident = ident.strip_prefix("r#").unwrap_or(&ident); + let name = match (&attrs.rename, rename_all) { + (Some(name), _) => name.clone(), + (None, Some(rule)) => field_case(ident, rule).ok_or_else(|| unknown_rule(f, rule))?, + (None, None) => ident.to_owned(), + }; + let none_skipped = attrs.skip_serializing_if.as_deref() == Some("Option::is_none"); + let (optional, ty) = match direction { + // `None` never crosses as `null` when serde skips it. + Direction::ToJs => match option_inner(&f.ty) { + Some(inner) if none_skipped => (true, inner), + _ => (attrs.skip_serializing_if.is_some(), &f.ty), + }, + // serde reads a missing `Option` field as `None`. + Direction::FromJs => (attrs.default || option_inner(&f.ty).is_some(), &f.ty), + }; + let mark = if optional { "?" } else { "" }; + out.push(quote! { + ::std::format!( + "{}{}: {}", + ::dunky_core::__private::ts_key(#name), + #mark, + <#ty as ::dunky_core::TsType>::ts(), + ) + }); + } + Ok(out) +} + +/// An object type from member expressions. +pub(crate) fn object(members: &[TokenStream2]) -> TokenStream2 { + quote!(::dunky_core::__private::ts_object(&[#(#members),*])) +} + +/// The member that names a variant of a tagged union: `type: 'open'`. +pub(crate) fn tag_member(tag: &str, name: &str) -> TokenStream2 { + quote! { + ::std::format!( + "{}: {}", + ::dunky_core::__private::ts_key(#tag), + ::dunky_core::__private::ts_literal(#name), + ) + } +} + +/// Join type expressions into a union (`never` when there are none). +pub(crate) fn union(types: &[TokenStream2]) -> TokenStream2 { + if types.is_empty() { + return quote!(::std::string::String::from("never")); + } + quote!([#(#types),*].join(" | ")) +} + +/// `impl TsType` with the body `ts`, bounding every type parameter by `TsType`. +pub(crate) fn impl_ts(input: &DeriveInput, ts: TokenStream2) -> TokenStream2 { + let name = &input.ident; + let mut generics = input.generics.clone(); + for param in generics.type_params_mut() { + param.bounds.push(syn::parse_quote!(::dunky_core::TsType)); + } + let (impl_g, ty_g, where_g) = generics.split_for_impl(); + quote! { + impl #impl_g ::dunky_core::TsType for #name #ty_g #where_g { + fn ts() -> ::std::string::String { + #ts + } + } + } +} + +/// `#[derive(TsType)]`: the value as JS reads it (serialized with serde). +pub(crate) fn expand(input: DeriveInput) -> syn::Result { + let container = serde_attrs(&input.attrs)?; + let ts = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) if !container.transparent => object(&members( + &fields.named, + container.rename_all.as_deref(), + Direction::ToJs, + )?), + Fields::Named(fields) if fields.named.len() == 1 => { + let ty = &fields.named[0].ty; + quote!(<#ty as ::dunky_core::TsType>::ts()) + } + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let ty = &fields.unnamed[0].ty; + quote!(<#ty as ::dunky_core::TsType>::ts()) + } + Fields::Unit => quote!(::std::string::String::from("null")), + _ => { + return Err(syn::Error::new_spanned( + &input.ident, + "TsType: use named fields, or one field (a newtype)", + )); + } + }, + Data::Enum(data) => { + let mut variants = Vec::new(); + for v in &data.variants { + let attrs = serde_attrs(&v.attrs)?; + if attrs.skip || attrs.skip_serializing { + continue; + } + let ident = v.ident.to_string(); + let name = match (&attrs.rename, &container.rename_all) { + (Some(name), _) => name.clone(), + (None, Some(rule)) => { + serde_case(&ident, rule).ok_or_else(|| unknown_rule(&v.ident, rule))? + } + (None, None) => ident, + }; + let fields_rule = attrs + .rename_all + .as_deref() + .or(container.rename_all_fields.as_deref()); + variants.push(match (&container.tag, &v.fields) { + (None, Fields::Unit) => quote!(::dunky_core::__private::ts_literal(#name)), + (Some(tag), Fields::Unit) => object(&[tag_member(tag, &name)]), + (Some(tag), Fields::Named(fields)) => { + let mut all = vec![tag_member(tag, &name)]; + all.extend(members(&fields.named, fields_rule, Direction::ToJs)?); + object(&all) + } + _ => { + return Err(syn::Error::new_spanned( + v, + "TsType: an enum with data needs #[serde(tag = \"...\")] and named fields", + )); + } + }); + } + union(&variants) + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "TsType: unions are not supported", + )); + } + }; + Ok(impl_ts(&input, ts)) +} diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml index d539ef5..b451e7e 100644 --- a/crates/sandbox/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -1,14 +1,28 @@ [package] name = "dunky-sandbox" -description = "The sandbox machines written in Rust (the command palette, a dialog): twins of the TS ones." +description = "The sandbox machines written in Rust (the command palette, a dialog), exported to wasm for every sandbox through fromWasm." version.workspace = true edition.workspace = true license.workspace = true publish = false +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# The JS classes (export_machine!), on for the wasm build (pnpm build:wasm) only, so +# crates reusing the machines (crates/uniffi) never link the JS exports. +wasm = ["dep:dunky-wasm", "dep:wasm-bindgen"] + [dependencies] dunky-core = { workspace = true } +dunky-wasm = { workspace = true, optional = true } serde = { workspace = true } +wasm-bindgen = { workspace = true, optional = true } [dev-dependencies] serde_json = { workspace = true } + +[[example]] +name = "typescript" +required-features = ["wasm"] diff --git a/crates/sandbox/examples/typescript.rs b/crates/sandbox/examples/typescript.rs new file mode 100644 index 0000000..303b90b --- /dev/null +++ b/crates/sandbox/examples/typescript.rs @@ -0,0 +1,6 @@ +//! Prints the TS types of the exported classes; `pnpm build:wasm` appends them to the +//! module's `.d.ts`, where `fromWasm` reads them. + +fn main() { + print!("{}", dunky_sandbox::typescript()); +} diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs index 7e48ffa..d5d24f5 100644 --- a/crates/sandbox/src/lib.rs +++ b/crates/sandbox/src/lib.rs @@ -5,7 +5,59 @@ //! (computed filtering, logical navigation). //! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). //! -//! `crates/uniffi` runs the palette on React Native. +//! `crates/uniffi` runs the palette on React Native. With the `wasm` feature, both are JS +//! classes too (`pnpm build:wasm` builds them to `crates/sandbox/pkg`): every sandbox +//! runs the palette on them with `?machine=rust` (`--machine=rust` in the terminal). pub mod dialog; pub mod palette; + +#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))] +pub use exports::typescript; + +#[cfg(feature = "wasm")] +mod exports { + use dunky_core::{Config, Machine}; + use wasm_bindgen::prelude::*; + + use crate::{dialog, palette}; + + // One config per machine type, shared by every instance (the shape a real app has). + thread_local! { + static PALETTE: Config = palette::config(Vec::new()); + static DIALOG: Config = dialog::config(0); + } + + dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // Malformed commands throw: the host maps result indices onto its own array. + dunky_wasm::from_js::>(commands) + .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) + } + computed { + palette::RESULTS => Vec, + palette::ACTIVE_ID => Option, + } + // Only the JS side's `results` mapping reads it: not part of the machine's types. + internal { + palette::RESULT_INDICES => Vec, + } + } + + dunky_wasm::export_machine! { + /// A dialog whose `closing` phase lasts `exitMs`. + pub struct DialogMachine(dialog::Dialog); + new(exit_ms: u32) { + DIALOG.with(|c| Machine::with_context(c, dialog::DialogCtx { exit_ms, open_count: 0 })) + } + computed {} + } + + /// The classes' TS types (`examples/typescript.rs`, run by `pnpm build:wasm`). + #[cfg(not(target_arch = "wasm32"))] + pub fn typescript() -> String { + PALETTE.with(PaletteMachine::typescript) + &DIALOG.with(DialogMachine::typescript) + } +} diff --git a/crates/sandbox/src/palette.rs b/crates/sandbox/src/palette.rs index cb06d04..cb6a8e5 100644 --- a/crates/sandbox/src/palette.rs +++ b/crates/sandbox/src/palette.rs @@ -4,10 +4,10 @@ //! wraparound, clamp) lives in context + computed, driven by logical events. A renderer //! never computes an index: it sends `move` / `query.set` and reads `results` / `activeId`. -use dunky_core::{ComputedKey, Config, Context, Event, State, Types}; +use dunky_core::{ComputedKey, Config, Context, Event, State, TsType, Types}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(TsType, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Command { pub id: String, pub label: String, @@ -18,7 +18,7 @@ pub struct Command { } /// Stamped on every execute; the nonce makes each execution a distinct value. -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(TsType, Clone, Debug, PartialEq, Serialize)] pub struct Executed { pub id: String, pub nonce: u32, @@ -30,7 +30,7 @@ pub enum PaletteState { Open, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[derive(TsType, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MoveTo { Up, diff --git a/crates/uniffi/src/lib.rs b/crates/uniffi/src/lib.rs index 21fe352..70c2203 100644 --- a/crates/uniffi/src/lib.rs +++ b/crates/uniffi/src/lib.rs @@ -3,7 +3,7 @@ //! //! Events go in by kind index; a u32 change mask comes out, and timers come out as //! flat commands (`dunky_core::protocol`). Values cross as JSON strings instead of JS -//! values. Pending its rework onto the host protocol `crates/wasm` speaks. +//! values. It still needs a TS facade over this handle (like `fromWasm` for wasm). use std::mem::ManuallyDrop; use std::sync::Arc; @@ -16,8 +16,7 @@ use dunky_sandbox::palette::{self, Palette, PaletteEvent}; uniffi::setup_scaffolding!(); -// The change mask and the command encoding are the shared protocol (the TS adapter -// decodes every binding with the same code): `dunky_core::protocol`. +// The change mask and the command encoding: `dunky_core::protocol`. use dunky_core::protocol::{FIELD_SHIFT, HIGH_FIELD, encode_commands, take_change_mask}; /// Bad input from JS, thrown as an `Error` with this message. diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..11ee9a4 --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-wasm" +description = "Run dunky-core machines from JavaScript: export a Rust machine to a JS class with one macro." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +dunky-core = { workspace = true } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +serde = { workspace = true } +serde-wasm-bindgen = { workspace = true } diff --git a/crates/wasm/README.md b/crates/wasm/README.md new file mode 100644 index 0000000..c669dbe --- /dev/null +++ b/crates/wasm/README.md @@ -0,0 +1,52 @@ +# dunky-wasm + +Run [`dunky-core`](../core) machines from JavaScript. Export a machine written in Rust +with one macro; wrap an instance with `fromWasm` from `@dunky.dev/state-machine-wasm`, and +the connector, `useMachine` and every target consume it like a machine written in TS. + +```rust +use dunky_core::{Config, Machine}; +use wasm_bindgen::prelude::*; + +thread_local!(static CONFIG: Config = palette::config(Vec::new())); + +dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands) + .map(|c| CONFIG.with(|config| Machine::with_context(config, palette::context(c)))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } + // Optional: served too, but left out of the machine's TS types. + internal { palette::RESULT_INDICES => Vec } +} +``` + +Build it, add its TS types, then wrap it: + +```bash +cargo build --target wasm32-unknown-unknown --release +wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg target/wasm32-unknown-unknown/release/my_machines.wasm +# Natively, PaletteMachine::typescript(&config) returns the class's TS types, generated +# from the Rust types: print them from a small binary or example, onto the .d.ts. +cargo run --example typescript >> pkg/my_machines.d.ts +``` + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/my_machines.js' + +await init() +// Machine<'closed' | 'open', { commands: ...; query: string; ... }, ...>: from Rust. +const palette = fromWasm(new PaletteMachine(commands)) +``` + +The data types the machine shows JS derive `TsType` (`#[derive(TsType)]`), next to +their serde derives. + +The crate using the macro also depends on `wasm-bindgen` (the version this crate pins), +and its event type derives `#[event(deserialize)]`. + +What the protocol guarantees: [`SPEC.md`](./SPEC.md). diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md new file mode 100644 index 0000000..ab36bf4 --- /dev/null +++ b/crates/wasm/SPEC.md @@ -0,0 +1,125 @@ +# SPEC - `dunky-wasm` + +## Overview + +`dunky-wasm` runs machines written in Rust ([`dunky-core`](../core/SPEC.md)) from +JavaScript. `export_machine!` turns a machine type into a JS class, in the author's own +wasm module; [`@dunky.dev/state-machine-wasm`](../../packages/wasm) wraps an instance +with `fromWasm` in the `Machine` interface, so the connector and every target consume +it next to the machines of the TS engine. + +``` + Rust machine (dunky-core types) + | + | export_machine! (your crate, your wasm module) + v + exported class + | the protocol + | events in .... by kind number + the JS event + | notify ....... once per change, synchronously + | timers ....... on the host clock + v + fromWasm: the JS host + the Machine facade (@dunky.dev/state-machine-wasm) + | + v + connect() / useMachine / every target +``` + +## Intent + +Write a machine once in Rust and use it in a Rust program, on React Native, and in JS, +with the same behavior. In JS the machine still runs entirely in Rust; JS keeps a mirror +of its context and fans each change out to the subscribers. + +## Scope & boundaries + +**In scope.** The protocol; the export macro; the conversion helpers between JS values +and Rust values; the TS types of an exported class. + +**Out of scope — invariants, not preferences.** + +- **No facade.** The `Machine` interface, the names (states, event types, fields) and the + subscribers live in `@dunky.dev/state-machine-wasm`. +- **No TS machines.** Machines written in TypeScript run on the TS engine + (`@dunky.dev/state-machine`), never on this binding. +- **No platform.** Only the JS host's own functions are called: no DOM, no `window`. +- **No strong JS references.** Rust holds no JS object of a machine (see below). +- **Dunky-specific.** This is Dunky's JS binding, not a general Rust/JS bridge (that is + `wasm-bindgen`, which it builds on). + +## The protocol + +### The facade stays in JS + +- Rust calls the host only during a call into the machine, so JS keeps the **facade** + whose call is in progress itself (saved and restored around each call, as calls + nest), and every host function runs for it. No reference to the facade crosses: a + call carries only numbers (and the JS event). Rust holds no JS object of a machine, so + no reference cycle through wasm keeps it alive: only JS references do. + +### The host + +- One JS host object serves every machine of a module. Its functions take numbers and + return status codes, so each call crosses the boundary once, with nothing to box or + free. They never throw: each catches what a subscriber throws, keeps the first error + on the facade, and returns a failure status. +- `notify(state, changedLo, changedHi)`: once per effective change, in order, + synchronously, with the current state and the context fields changed since the + previous notify (the low and high 32 bits of the mask). The binding hears the changes + through the engine's own subscription, so the engine needs no hook for it. +- `startTimer(id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels it. A + due timer calls back into the machine with its id. + +### Calls + +- Each call into a machine runs to completion and returns a status: 0, 1 when a + subscriber failed during this call (the facade holds the error), 3 when the binding + failed (a malformed event, a second attach: its message comes from `takeFailure`). +- A subscriber's failure does not stop the step: the call finishes, then the facade + throws the error. +- The **outermost** call also runs the timer commands. A **nested** call (a subscriber + calling back in) reports only a failure it caused; the outer call reports it too. +- After a failure the machine stays usable. + +### Timers + +- The engine's timer commands run on the host clock. A pending timer is held by the + host's closure, so it keeps its machine alive, like a JS timer holds `this`. + +### Values + +- Values convert with serde: `None` becomes `null`. A derived value is serialized only + when its version changed, and the same JS value is served until then, so its identity + marks a change. + +## The export macro + +- The macro takes the class name, the machine type, a constructor body, and the derived + values the class serves, each with its key and its type; an optional `internal` block + lists derived values it serves but leaves out of its types. A constructor body that + returns an error throws it from the JS constructor; bad arguments read as + `[machine] bad argument: ...`. +- The class implements the protocol: attach to a host (once), dispatch by kind, start, + stop, report whether it runs, report the state, read a context field, read a derived + value, take a due timer, and report the names (states, event types, fields, derived + values, tags per state) the facade maps the numbers onto. +- The class works on its own too: `start()`, `send(kind, event)`. Once attached, call it + through its `fromWasm` facade only: the host functions run for the facade whose call + is in progress. +- An event kind without data is built from the number alone; any other kind is read from + the JS event's fields. The event type opts in to that with `#[event(deserialize)]`. +- The crate using the macro depends on `wasm-bindgen` at the version this crate pins. +- **Types.** Natively, the class reports its TS types (`typescript(&config)`), generated + from the machine's Rust types: the states, the context, the events, and the derived + values outside `internal`. The build appends them to the module's `.d.ts` as a + `__types` member of the class, which exists only in the types; `fromWasm` infers the + machine's types from it. A Rust type change changes the TS types on the next build. +- Freeing the class frees the machine: the engine subscription that feeds `notify` is + undone when the class drops. + +## Edge cases that carry design meaning + +- Attaching a machine to a host twice is a binding failure. +- An unknown event kind is an error; the facade filters unknown event types before. +- A malformed payload is an error that names the event type. +- Commands emitted before a machine was attached run when it attaches. diff --git a/crates/wasm/src/bridge.rs b/crates/wasm/src/bridge.rs new file mode 100644 index 0000000..f849271 --- /dev/null +++ b/crates/wasm/src/bridge.rs @@ -0,0 +1,188 @@ +//! What every machine exported to JS (`export_machine!`) shares: the JS host it calls +//! back into, the failure flag of a call, and the host clock that runs its timers. +//! +//! The host is one JS object per module. Its functions take numbers and return status +//! codes, so a call crosses the boundary once, with nothing to box or free. They run for +//! the machine whose call is in progress, which JS keeps track of itself: no reference to +//! the facade crosses, and Rust holds no JS object of a machine, so no JS <-> wasm +//! reference cycle keeps a machine alive. +//! +//! JS owns the errors: a host function catches what a subscriber throws, keeps it, and +//! returns a failure status. Rust finishes the call and reports the status back. + +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use dunky_core::{Changes, Command, Machine, Types}; +use wasm_bindgen::prelude::*; + +/// A call finished. +pub const OK: u32 = 0; +/// User code failed during the call; the JS side holds the error. +pub const FAILED: u32 = 1; +/// The binding failed (a malformed event, a second attach): read `takeFailure()`. +pub const ENGINE_FAILED: u32 = 3; + +#[wasm_bindgen] +extern "C" { + /// The JS host object. + #[derive(Clone)] + pub type HostObject; + + /// Once per effective change. Returns 0, or 1 when an observer threw. + #[wasm_bindgen(method)] + fn notify(this: &HostObject, state: u32, lo: u32, hi: u32) -> u32; + + /// Returns the host's timeout. + #[wasm_bindgen(method, js_name = startTimer)] + fn start_timer(this: &HostObject, id: u32, ms: u32) -> JsValue; + + #[wasm_bindgen(method, js_name = cancelTimer)] + fn cancel_timer(this: &HostObject, timeout: &JsValue); +} + +/// One machine's line to the JS host. +pub(crate) struct Link { + pub(crate) host: HostObject, + /// A host function failed during the current call. + failed: Cell, +} + +impl Link { + pub(crate) fn new(host: HostObject) -> Self { + Self { + host, + failed: Cell::new(false), + } + } + + pub(crate) fn fail(&self) { + self.failed.set(true); + } + + pub(crate) fn failed(&self) -> bool { + self.failed.get() + } + + pub(crate) fn notify(&self, state: u32, changes: Changes) { + let (lo, hi) = (changes.fields as u32, (changes.fields >> 32) as u32); + if self.host.notify(state, lo, hi) != OK { + self.fail(); + } + } +} + +/// A machine exported to JS: runs each call to completion, then the timer commands, and +/// reports a status. +pub struct Bridge { + machine: Machine, + link: OnceCell>, + /// Pending timers: the engine's timer id, and the host's timeout. + timers: RefCell>, + /// Nesting of calls (JS code calls back in); the outermost one runs the timers. + depth: Cell, + /// The binding's own failure, until JS takes it. + failure: RefCell>, +} + +impl Bridge { + pub fn new(machine: Machine) -> Self { + Self { + machine, + link: OnceCell::new(), + timers: RefCell::default(), + depth: Cell::new(0), + failure: RefCell::new(None), + } + } + + pub fn machine(&self) -> &Machine { + &self.machine + } + + /// Connect to the JS host. Returns false when already connected. + pub(crate) fn connect(&self, link: Rc) -> bool { + self.link.set(link).is_ok() + } + + /// Run `f` on the machine and report a status. The outermost call also runs the + /// timer commands. `FAILED` means user code failed during this very call: a nested + /// call (JS calling back in) does not report a failure from before it. + pub fn call(&self, f: impl FnOnce(&Machine)) -> u32 { + let depth = self.depth.get(); + let link = self.link.get(); + let failed_before = link.is_some_and(|l| l.failed()); + self.depth.set(depth + 1); + f(&self.machine); + self.depth.set(depth); + let failed = link.is_some_and(|l| l.failed()); + if depth > 0 { + return if failed && !failed_before { FAILED } else { OK }; + } + match link { + Some(link) => { + self.run_timers(link); + if link.failed.replace(false) { + FAILED + } else { + OK + } + } + None => OK, + } + } + + /// Keep the binding's `message` for `take_failure`. + pub fn fail(&self, message: String) -> u32 { + *self.failure.borrow_mut() = Some(message); + ENGINE_FAILED + } + + pub fn take_failure(&self) -> Option { + self.failure.borrow_mut().take() + } + + pub fn start(&self) -> u32 { + self.call(|m| m.start()) + } + + pub fn stop(&self) -> u32 { + self.call(|m| m.stop()) + } + + /// The host clock: timer `id` came due. + pub fn fire(&self, id: u32) -> u32 { + { + let mut timers = self.timers.borrow_mut(); + if let Some(pos) = timers.iter().position(|(t, _)| *t == id) { + timers.swap_remove(pos); + } + } + self.call(|m| m.fire_timer(id)) + } + + /// The core emits timer commands; the host runs them on its clock (`setTimeout`). + pub(crate) fn run_timers(&self, link: &Link) { + if !self.machine.has_commands() { + return; + } + for command in self.machine.take_commands() { + match command { + Command::StartTimer { id, ms } => { + let timeout = link.host.start_timer(id, ms); + self.timers.borrow_mut().push((id, timeout)); + } + Command::CancelTimer { id } => { + let timeout = { + let mut timers = self.timers.borrow_mut(); + let pos = timers.iter().position(|(t, _)| *t == id); + pos.map(|pos| timers.swap_remove(pos).1) + }; + if let Some(timeout) = timeout { + link.host.cancel_timer(&timeout); + } + } + } + } + } +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..0e1802c --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,382 @@ +//! `dunky-wasm` — run `dunky-core` machines from JavaScript. +//! +//! [`export_machine!`] turns a machine written in Rust into a JS class, in your own wasm +//! module. `@dunky.dev/state-machine-wasm` wraps an instance with `fromWasm`, so the +//! connector and every target — react, solid, native, opentui — consume it as a +//! `Machine`, next to the machines of the TS engine. +//! +//! A class speaks one protocol with that package's JS host: events in by kind number, +//! a `notify(state, changedLo, changedHi)` per effective change (synchronously, +//! like the TS subscribers), timers on the host clock (`startTimer` / `cancelTimer`, then +//! `fire(id)`), and a status code back from every call (see [`bridge`]). +//! +//! The crate using the macro must also depend on `wasm-bindgen`. +//! +//! A class also knows its TS types (`typescript`, natively): they come from the machine's +//! Rust types (`TsType`), and `pnpm build:wasm` adds them to the module's `.d.ts`, where +//! `fromWasm` reads them. + +use std::cell::RefCell; +use std::rc::Rc; + +use dunky_core::{ + ComputedKey, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, + Subscription, Types, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +pub mod bridge; + +use bridge::Link; +pub use bridge::{Bridge, ENGINE_FAILED, FAILED, HostObject, OK}; + +#[doc(hidden)] +pub mod __private { + pub use dunky_core::{ComputedKey, Config, TsType}; + + use dunky_core::__private::{ts_key, ts_object}; + use dunky_core::{Context, Types}; + + /// The declaration that gives exported class `class` its machine's types: a + /// `__types` member, only in the types, that `fromWasm` reads. `computed` holds the + /// computed values of the types by id, with their TS types. + pub fn class_types( + class: &str, + config: &Config, + computed: &[(usize, String)], + ) -> String + where + T::State: TsType, + T::Event: TsType, + T::Context: TsType, + { + let names: Vec<&str> = config.computed_names().collect(); + let computed: Vec = computed + .iter() + .map(|(id, ts)| format!("{}: {ts}", ts_key(names[*id]))) + .collect(); + // The facade builds the context from the fields: none gives an empty object. + let empty = || String::from("Record"); + let context = if ::FIELDS.is_empty() { + empty() + } else { + ::ts() + }; + let computed = if computed.is_empty() { + empty() + } else { + ts_object(&computed) + }; + format!( + "\nexport interface {class} {{\n /** Generated from the Rust machine's types; `fromWasm` reads them. */\n readonly __types?: {{\n state: {};\n context: {};\n event: {};\n computed: {};\n }};\n}}\n", + ::ts(), + context, + ::ts(), + computed, + ) + } +} + +/// What an [`export_machine!`] constructor body may evaluate to: a machine, or a +/// `Result` whose error is thrown from the JS constructor. +pub trait IntoMachine { + fn into_machine(self) -> Result, JsError>; +} + +impl IntoMachine for Machine { + fn into_machine(self) -> Result, JsError> { + Ok(self) + } +} + +impl IntoMachine for Result, JsError> { + fn into_machine(self) -> Result, JsError> { + self + } +} + +/// Deserialize a JS value (constructor arguments, props). +pub fn from_js(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value).map_err(|e| { + let error: js_sys::Error = JsValue::from(e).unchecked_into(); + JsError::new(&format!( + "[machine] bad argument: {}", + String::from(error.message()) + )) + }) +} + +// `None` becomes `null`, matching the TS machines (`lastExecuted: null`, `activeId: null`). +fn serializer() -> serde_wasm_bindgen::Serializer { + serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true) +} + +/// Serialize any value for JS (plain objects, arrays, `null` for `None`). +pub fn to_js(value: &V) -> JsValue { + value.serialize(&serializer()).unwrap_or(JsValue::UNDEFINED) +} + +/// The implementation behind every class [`export_machine!`] generates. +pub struct Exported { + bridge: Bridge, + /// Per computed value: the version and the JS value last served. + computed: RefCell>>, + /// The engine subscription that tells JS each change (set on attach). It holds the + /// machine, so it is undone on drop: the class is freed, the cycle goes with it. + changes: RefCell>, +} + +impl Drop for Exported { + fn drop(&mut self) { + if let Some(sub) = self.changes.get_mut().take() { + sub.unsubscribe(); + } + } +} + +impl Exported +where + T::Event: DeserializeEvent, + T::Context: SerializeFields, +{ + pub fn new(machine: Machine) -> Self { + let count = machine.config().computed_names().count(); + Self { + bridge: Bridge::new(machine), + computed: RefCell::new(vec![None; count]), + changes: RefCell::new(None), + } + } + + pub fn machine(&self) -> &Machine { + self.bridge.machine() + } + + /// Connect to the JS host. Returns a status; a second attach is an engine failure. + /// From then on, call the machine through its `fromWasm` facade: the host functions + /// run for the facade whose call is in progress. + pub fn attach(&self, host: HostObject) -> u32 { + let link = Rc::new(Link::new(host)); + if !self.bridge.connect(link.clone()) { + return self.bridge.fail("[machine] already attached".into()); + } + // Once per effective change, in order, with what changed since the last one. + let machine = self.machine().clone(); + let sub = self.machine().subscribe(move || { + let changes = machine.take_changes(); + link.notify(machine.state().index() as u32, changes); + }); + *self.changes.borrow_mut() = Some(sub); + // Run what the machine did before it had a host (e.g. timers of a start in Rust). + self.bridge.call(|_| {}) + } + + /// Send event `kind`; `event` (the JS event object) supplies the payload, if any. + pub fn send(&self, kind: u32, event: JsValue) -> u32 { + let names = ::KIND_NAMES; + let Some(name) = names.get(kind as usize) else { + return self.bridge.fail(format!("[machine] no event type #{kind}")); + }; + let k = ::kind_from_index(kind as usize); + let event = match ::from_kind(k) { + Some(event) => event, + None => { + let deserializer = serde_wasm_bindgen::Deserializer::from(event); + match ::deserialize_payload( + kind as usize, + deserializer, + ) { + Ok(event) => event, + Err(e) => { + return self + .bridge + .fail(format!("[machine] bad \"{name}\" event: {e}")); + } + } + } + }; + self.bridge.call(|m| m.send(event)) + } + + pub fn start(&self) -> u32 { + self.bridge.start() + } + + pub fn stop(&self) -> u32 { + self.bridge.stop() + } + + pub fn fire(&self, id: u32) -> u32 { + self.bridge.fire(id) + } + + /// The engine's failure behind status 3. + pub fn take_failure(&self) -> Option { + self.bridge.take_failure() + } + + pub fn running(&self) -> bool { + self.machine().is_running() + } + + pub fn state(&self) -> u32 { + self.machine().state().index() as u32 + } + + pub fn field(&self, index: u32) -> JsValue { + self.machine() + .context() + .serialize_field(index as usize, &serializer()) + .unwrap_or(JsValue::UNDEFINED) + } + + /// The value of `key`, serialized only when it changed since the last read. + pub fn computed(&self, key: ComputedKey) -> JsValue { + let version = self.machine().computed_version(key.id()); + if let Some((seen, value)) = &self.computed.borrow()[key.id()] + && *seen == version + { + return value.clone(); + } + let value = to_js(&*self.machine().computed(key)); + self.computed.borrow_mut()[key.id()] = Some((version, value.clone())); + value + } + + /// The names `fromWasm` maps the numbers onto, once per machine type. + pub fn meta(&self) -> JsValue { + #[derive(Serialize)] + struct Meta<'a> { + states: &'a [&'a str], + events: &'a [&'a str], + fields: &'a [&'a str], + computed: Vec<&'a str>, + tags: Vec<&'a [&'a str]>, + } + let config = self.machine().config(); + let states = ::NAMES; + to_js(&Meta { + states, + events: ::KIND_NAMES, + fields: ::FIELDS, + computed: config.computed_names().collect(), + tags: (0..states.len()) + .map(|i| config.tags(::from_index(i))) + .collect(), + }) + } +} + +/// Export a machine type as a JS class. Wrap an instance with `fromWasm` from +/// `@dunky.dev/state-machine`. +/// +/// ```ignore +/// dunky_wasm::export_machine! { +/// /// The command palette. +/// pub struct PaletteMachine(palette::Palette); +/// new(commands: JsValue) { +/// // A `Result` body throws its error from the JS constructor. +/// dunky_wasm::from_js(commands).map(|c| Machine::new(&palette::config(c))) +/// } +/// computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +/// // Optional: served too, but left out of the machine's TS types. +/// internal { palette::RESULT_INDICES => Vec } +/// } +/// ``` +/// +/// Natively, the class also has `typescript(&config)`: its TS types, from the machine's +/// Rust types, for `pnpm build:wasm` to add to the module's `.d.ts`. +#[macro_export] +macro_rules! export_machine { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident($types:ty); + new($($arg:ident : $argty:ty),* $(,)?) $make:block + computed { $($key:expr => $cty:ty),* $(,)? } + $(internal { $($ikey:expr => $ity:ty),* $(,)? })? + ) => { + $(#[$meta])* + #[::wasm_bindgen::prelude::wasm_bindgen] + $vis struct $name { + inner: $crate::Exported<$types>, + } + + #[::wasm_bindgen::prelude::wasm_bindgen] + impl $name { + /// Throws when the constructor body returns an error (bad arguments). + #[wasm_bindgen(constructor)] + #[allow(unused_braces)] // `$make` is the caller's block, passed as an argument + pub fn new($($arg: $argty),*) -> ::core::result::Result<$name, ::wasm_bindgen::JsError> { + let machine = $crate::IntoMachine::<$types>::into_machine($make)?; + ::core::result::Result::Ok($name { inner: $crate::Exported::new(machine) }) + } + /// Connect to the JS host (`fromWasm` does). Calls return a status: 0, 1 when + /// JS code failed (the host holds the error), 3 when the engine failed. + pub fn attach(&self, host: $crate::HostObject) -> u32 { + self.inner.attach(host) + } + pub fn send(&self, kind: u32, event: ::wasm_bindgen::JsValue) -> u32 { + self.inner.send(kind, event) + } + pub fn start(&self) -> u32 { + self.inner.start() + } + pub fn stop(&self) -> u32 { + self.inner.stop() + } + pub fn fire(&self, id: u32) -> u32 { + self.inner.fire(id) + } + #[wasm_bindgen(js_name = takeFailure)] + pub fn take_failure(&self) -> ::core::option::Option<::std::string::String> { + self.inner.take_failure() + } + pub fn running(&self) -> bool { + self.inner.running() + } + pub fn state(&self) -> u32 { + self.inner.state() + } + pub fn field(&self, index: u32) -> ::wasm_bindgen::JsValue { + self.inner.field(index) + } + #[allow(unused_variables)] + pub fn computed(&self, id: u32) -> ::wasm_bindgen::JsValue { + $( + let key: $crate::__private::ComputedKey<$cty> = $key; + if id as usize == key.id() { + return self.inner.computed(key); + } + )* + $($( + let key: $crate::__private::ComputedKey<$ity> = $ikey; + if id as usize == key.id() { + return self.inner.computed(key); + } + )*)? + ::wasm_bindgen::JsValue::UNDEFINED + } + pub fn meta(&self) -> ::wasm_bindgen::JsValue { + self.inner.meta() + } + } + + #[cfg(not(target_arch = "wasm32"))] + impl $name { + /// This class's TS types, from the machine's Rust types. + pub fn typescript(config: &$crate::__private::Config<$types>) -> ::std::string::String { + $crate::__private::class_types::<$types>( + ::core::stringify!($name), + config, + &[$({ + let key: $crate::__private::ComputedKey<$cty> = $key; + (key.id(), <$cty as $crate::__private::TsType>::ts()) + }),*], + ) + } + } + }; +} diff --git a/package.json b/package.json index d835914..45831a4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", + "build:wasm": "./scripts/build-wasm.sh", "typecheck": "tsc -b tsconfig.build.json", "lint": "oxlint .", "format": "oxfmt .", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6be01f8..4454374 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { machine } from './machine' +export { machine, toMachine, type MachineSource } from './machine' export { MACHINE_INIT } from './constants' export { setup } from './setup' export { and, or, not } from './guards' @@ -7,6 +7,10 @@ export { connector } from './connector' export { makeReaction } from './reaction' export { compose, type Composition } from './compose' export { createStore, type Store, type Listener, type SetStateAction } from './store' +// Building blocks for machines that run outside this engine (e.g. Rust machines through +// @dunky.dev/state-machine-wasm), so their subscriptions behave exactly like this engine's. +export { makeBroadcast, type Broadcast } from './broadcast' +export { makeSelection } from './selection' export type { Machine, diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 0d97796..3cb1dc5 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -323,3 +323,29 @@ export function machine< ): Machine { return new MachineClass(config) as unknown as Machine } + +/** + * What a target can build a service from: a config (run by this engine), or a ready + * machine — e.g. a Rust machine wrapped with `fromWasm` from + * `@dunky.dev/state-machine-wasm`. + */ +export type MachineSource< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +> = TransitionConfig | Machine + +/** Build a stopped service from a config; a ready machine is returned as is. */ +export function toMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + source: MachineSource, +): Machine { + return typeof (source as Machine).send === 'function' + ? (source as Machine) + : machine(source as TransitionConfig) +} diff --git a/packages/react/src/use-machine.ts b/packages/react/src/use-machine.ts index 541d6f8..74b11af 100644 --- a/packages/react/src/use-machine.ts +++ b/packages/react/src/use-machine.ts @@ -1,5 +1,11 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + connector, + toMachine, + type Connect, + type Machine, + type MachineSource, +} from '@dunky.dev/state-machine' /** * A substrate-specific effect: a setup/teardown function plus the prop names it depends on. @@ -19,9 +25,14 @@ export type ComponentEffect = [ ] /** - * The generic React bridge. Builds the machine once from the first render's props, + * The generic React bridge. Builds the machine once from the first render's props — + * from a config, or as a ready machine (e.g. a Rust machine via wasm) — * keeps props fresh via setProps, runs substrate effects, and drives React via * useSyncExternalStore over the connector's snapshot. + * + * A ready machine must come fresh from `createConfig` (one per component instance): + * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine + * between components is not supported. */ export function useMachine< State extends string, @@ -31,14 +42,14 @@ export function useMachine< Api, Computed = Record, >( - createConfig: (props: Props) => TransitionConfig, + createConfig: (props: Props) => MachineSource, connect: Connect, - effects: ComponentEffect>, Props>[], + effects: ComponentEffect, Props>[], props: Props, -): { api: Api; machine: ReturnType> } { +): { api: Api; machine: Machine } { const { service, connection } = useMemo( () => { - const service = machine(createConfig(props)) + const service = toMachine(createConfig(props)) const connection = connector(service, connect, props) return { service, connection } }, diff --git a/packages/react/tests/use-machine.test.tsx b/packages/react/tests/use-machine.test.tsx index 996aa71..48b2cc3 100644 --- a/packages/react/tests/use-machine.test.tsx +++ b/packages/react/tests/use-machine.test.tsx @@ -117,6 +117,25 @@ describe('useMachine — lifecycle', () => { }) }) +describe('useMachine — machine source', () => { + it('accepts a ready machine instead of a config and runs that very instance', () => { + // The shape a Rust machine takes (built outside the engine, e.g. via wasm). + const ready = machine(createConfig()({})) + const sink: { api?: ToggleApi; machine?: ToggleMachine } = {} + function Comp(p: ToggleProps) { + const result = useMachine(() => ready, connect, noEffects, p) + sink.api = result.api + sink.machine = result.machine + return null + } + render() + expect(sink.machine).toBe(ready) + act(() => sink.api!.toggle()) + expect(ready.state).toBe('open') + expect(sink.api!.open).toBe(true) + }) +}) + describe('useMachine — build once', () => { it('builds the machine ONCE: state survives prop changes (no rebuild)', () => { const { sink, Comp } = harness({ label: 'a' }) diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index 844f393..052cafb 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -1,5 +1,11 @@ import { createEffect, createStore, onCleanup, onSettled, reconcile } from 'solid-js' -import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + connector, + toMachine, + type Connect, + type Machine, + type MachineSource, +} from '@dunky.dev/state-machine' /** * One substrate-specific effect: a setup/teardown function plus the prop names @@ -13,10 +19,15 @@ export type ComponentEffect = [ ] /** - * The generic Solid bridge: builds the machine + connector once, mirrors the + * The generic Solid bridge: builds the machine (from a config, or a ready machine such + * as a Rust machine via wasm) + connector once, mirrors the * connector's snapshot into a fine-grained store, runs the lifecycle and the * component's effects. Returns the connect() api (reactive store proxy) and * the running machine. + * + * A ready machine must come fresh from `createConfig` (one per component instance): + * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine + * between components is not supported. */ export function useMachine< State extends string, @@ -26,14 +37,14 @@ export function useMachine< Api extends object, Computed = Record, >( - createConfig: (props: Props) => TransitionConfig, + createConfig: (props: Props) => MachineSource, connect: Connect, - effects: ComponentEffect>, Props>[], + effects: ComponentEffect, Props>[], props: Props, -): { api: Api; machine: ReturnType> } { +): { api: Api; machine: Machine } { // Seed with a plain copy, never the live props proxy: setProps value-dedups, // and a held proxy would compare equal to its own fresh spread and never wake. - const service = machine(createConfig(props)) + const service = toMachine(createConfig(props)) const connection = connector(service, connect, { ...props }) // Fine-grained mirror of the snapshot: reading `api.x` subscribes to that diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx index 47f5481..f4b449a 100644 --- a/packages/solid/tests/use-machine.test.tsx +++ b/packages/solid/tests/use-machine.test.tsx @@ -68,6 +68,25 @@ const noEffects: ComponentEffect[] = [] afterEach(() => vi.clearAllMocks()) +describe('useMachine — machine source', () => { + it('accepts a ready machine instead of a config and runs that very instance', () => { + // The shape a Rust machine takes (built outside the engine, e.g. via wasm). + const ready = machine(createConfig()({})) + let captured: { api: ToggleApi; machine: ToggleMachine } | undefined + function Comp() { + const props: ToggleProps = {} + captured = useMachine(() => ready, connect, noEffects, props) + return null + } + render(() => ) + expect(captured!.machine).toBe(ready) + captured!.api.toggle() + flush() + expect(ready.state).toBe('open') + expect(captured!.api.open).toBe(true) + }) +}) + describe('useMachine — lifecycle', () => { it('returns { api, machine }: api is the connect() output, machine is the running service', () => { let captured: { api: ToggleApi; machine: ToggleMachine } | undefined diff --git a/packages/wasm/LICENSE b/packages/wasm/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/wasm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan Banov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/wasm/README.md b/packages/wasm/README.md new file mode 100644 index 0000000..93fa695 --- /dev/null +++ b/packages/wasm/README.md @@ -0,0 +1,41 @@ +# @dunky.dev/state-machine-wasm + +Use a Dunky machine written in **Rust** from TypeScript. `fromWasm` wraps a class +exported with `dunky_wasm::export_machine!` (see [`crates/wasm`](../../crates/wasm)) in +the same `Machine` interface the TS engine returns, so `connect()`, `useMachine` and +every target consume it unchanged, next to your 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() +// Machine<'closed' | 'open', …>: the types come from the Rust types. +const palette = fromWasm(new PaletteMachine(commands)) +``` + +- **The machine runs in Rust**: its graph, user code, timers and computed values. The + facade keeps a JS mirror of the context and re-reads only the fields each change + names. +- **Typed from Rust**: the build generates each class's types from the machine's Rust + types; `fromWasm` infers `Machine` from them. For a + class built without them, name them: `fromWasm(…)`. +- **`computed` option**: map a raw computed value once per change — e.g. indices that + crossed the boundary, back onto your own objects: + + ```ts + fromWasm(new PaletteMachine(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as number[], i => commands[i]!), + }, + }, + }) + ``` + +- **In a target**: `useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props)`. + +In JS a TS machine is a little faster (its context already lives in JS); a Rust machine +is for reuse: the same machine runs in Rust programs and on React Native. The protocol: +[`crates/wasm/SPEC.md`](../../crates/wasm/SPEC.md). diff --git a/packages/wasm/package.json b/packages/wasm/package.json new file mode 100644 index 0000000..052208b --- /dev/null +++ b/packages/wasm/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/state-machine-wasm", + "version": "0.0.0", + "description": "Run Dunky machines written in Rust from TypeScript: fromWasm wraps a class exported with dunky_wasm::export_machine! in the Machine interface.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/wasm" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:*" + } +} diff --git a/packages/wasm/src/from-wasm.ts b/packages/wasm/src/from-wasm.ts new file mode 100644 index 0000000..9135f7f --- /dev/null +++ b/packages/wasm/src/from-wasm.ts @@ -0,0 +1,345 @@ +import { + makeBroadcast, + makeSelection, + type Broadcast, + type Machine, + type Select, + type Selection, +} from '@dunky.dev/state-machine' + +/** + * An instance of a class exported with `dunky_wasm::export_machine!`. Calls that run + * machine code return a status (see `OK`); the host functions run for the facade whose + * call is in progress (`calling`). + */ +export interface WasmMachine { + /** Connect to the host. Returns a status. */ + attach: (host: object) => number + send: (kind: number, event: unknown) => number + start: () => number + stop: () => number + running: () => boolean + state: () => number + field: (index: number) => unknown + computed: (id: number) => unknown + /** The host clock: timer `id` came due. */ + fire: (id: number) => number + /** The binding's failure behind `ENGINE_FAILED`. */ + takeFailure: () => string | undefined + meta: () => WasmMachineMeta +} + +/** The names a Rust machine type reports, in its numbering. */ +export interface WasmMachineMeta { + states: string[] + events: string[] + fields: string[] + computed: string[] + /** Per state: its tags. */ + tags: string[][] +} + +/** + * A Rust machine's types. `pnpm build:wasm` generates them from the machine's Rust types + * onto its exported class, as a `__types` member that exists only in the types. + */ +export interface MachineTypes { + state: string + context: object + event: { type: string } + computed: object +} + +/** The types generated onto an exported class; loose ones when it has none. */ +export type TypesOf = Handle extends { readonly __types?: infer Types extends MachineTypes } + ? Types + : MachineTypes + +/** How to produce one computed value on the JS side. */ +export type ComputedMapping = + | ((raw: unknown) => Value) + | { + /** Read this Rust computed value instead (e.g. indices instead of objects). */ + from: string + map: (raw: unknown) => Value + } + +export interface FromWasmOptions { + /** + * Map a raw computed value — e.g. indices that crossed the boundary, back onto the + * host's own objects. Runs only when the value changed. + */ + computed?: { [K in keyof Computed]?: ComputedMapping } +} + +// The statuses of a call (crates/wasm/src/bridge.rs). JS owns the errors: a host +// function catches what a subscriber throws and keeps it on the machine. +const OK = 0 +const FAILED = 1 +const ENGINE_FAILED = 3 +const NONE = Symbol('none') +const UNREAD = {} + +// The facade whose call is in progress: every host function runs for it. Calls nest +// (a subscriber calls another machine), so each call saves and restores it. Keeping it +// here means no reference to the facade crosses into wasm. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let calling: RustMachine = null! + +interface Shape { + stateNames: string[] + tags: Array> + kinds: Map + fields: string[] + computedIndex: Map +} + +// The shape is per machine TYPE: read it once per exported class, not per instance. +const shapes = new WeakMap() + +function shapeOf(handle: WasmMachine): Shape { + const type = Object.getPrototypeOf(handle) as object + let shape = shapes.get(type) + if (!shape) { + const meta = handle.meta() + shape = { + stateNames: meta.states, + tags: meta.tags.map(tags => new Set(tags)), + kinds: new Map(meta.events.map((name, i) => [name, i])), + fields: meta.fields, + computedIndex: new Map(meta.computed.map((name, i) => [name, i])), + } + shapes.set(type, shape) + } + return shape +} + +type Timeout = ReturnType + +/** The JS functions a Rust machine calls back into, for the facade of the call in + * progress. They never throw: an error is kept on the facade, and a status returned. */ +const HOST = { + notify: (state: number, lo: number, hi: number): number => { + const m = calling + try { + m.current = state + if (lo | hi) m.refresh(lo, hi) + m.broadcast.notify() + return OK + } catch (error) { + return m.fail(error) + } + }, + // The pending timer holds the machine, like a JS timer holds `this`. + startTimer: (id: number, ms: number): Timeout => { + const m = calling + return setTimeout(() => m.fire(id), ms) + }, + cancelTimer: (timeout: Timeout): void => clearTimeout(timeout), +} + +/** + * The `Machine` interface over a Rust machine. Rust runs the machine: its graph, user + * code, timers and computed values. The facade maps names to numbers, keeps a JS mirror + * of the context (re-reading only the fields each change names), and fans each change + * out to the subscribers. + */ +class RustMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +> implements Machine { + shape: Shape + handle: WasmMachine + ctx: Context + computed = {} as Computed + /** The current state's index, moved by every notify. */ + current: number + broadcast: Broadcast = makeBroadcast() + // Lazily created — a machine with no connector pays nothing. + startListeners: Set<() => void> | null = null + stopListeners: Set<() => void> | null = null + selectFacade: Select | null = null + /** The first error a subscriber threw in the current call. */ + error: unknown = NONE + /** Nesting of calls: JS code calls back in from inside one. */ + depth = 0 + /** Started and not stopped (mirrors Rust, so JS needs no call to ask). */ + isRunning: boolean + + constructor( + shape: Shape, + handle: WasmMachine, + mappings: Record | undefined>, + ) { + this.shape = shape + this.handle = handle + const ctx: Record = {} + for (let i = 0; i < shape.fields.length; i++) ctx[shape.fields[i]!] = handle.field(i) + this.ctx = ctx as Context + this.current = handle.state() + this.isRunning = handle.running() + for (const [name, own] of shape.computedIndex) { + const mapping = mappings[name] + const map = typeof mapping === 'function' ? mapping : mapping?.map + const source = typeof mapping === 'object' ? shape.computedIndex.get(mapping.from) : own + if (source === undefined) { + throw new Error(`[machine] no computed "${(mapping as { from: string }).from}"`) + } + // Rust serves the same JS value until it changes, so identity marks a change. + let raw: unknown = UNREAD + let value: unknown + Object.defineProperty(this.computed, name, { + enumerable: true, + get: map + ? () => { + const next = handle.computed(source) + if (next !== raw) { + raw = next + value = map(next) + } + return value + } + : () => handle.computed(source), + }) + } + this.run(() => handle.attach(HOST)) + } + + /** Run a call into Rust for this facade, and throw what failed in it. */ + run(call: () => number): void { + const outer = calling + calling = this + this.depth++ + const status = call() + this.depth-- + calling = outer + if (status !== OK) this.raise(status) + } + /** Keep the first error of the call. */ + fail(error: unknown): number { + if (this.error === NONE) this.error = error + return FAILED + } + /** Throw what failed: the outermost call takes the error, a nested one leaves it. */ + raise(status: number): never { + if (status === ENGINE_FAILED) throw new Error(this.handle.takeFailure()) + const error = this.error + if (this.depth === 0) this.error = NONE + throw error + } + /** A change named context fields `lo`/`hi` (the low and high 32 bits of the mask). */ + refresh(lo: number, hi: number): void { + const ctx = this.ctx as Record + const { fields } = this.shape + for (let bits = lo; bits !== 0; bits &= bits - 1) { + const i = 31 - Math.clz32(bits & -bits) + ctx[fields[i]!] = this.handle.field(i) + } + for (let bits = hi; bits !== 0; bits &= bits - 1) { + const i = 63 - Math.clz32(bits & -bits) + ctx[fields[i]!] = this.handle.field(i) + } + } + + send = (event: Event): void => { + const kind = this.shape.kinds.get(event.type) + if (kind === undefined) return // handled nowhere: a no-op + this.run(() => this.handle.send(kind, event)) + } + fire(id: number): void { + this.run(() => this.handle.fire(id)) + } + + get state(): State { + return this.shape.stateNames[this.current] as State + } + get context(): Context { + return this.ctx + } + hasTag(tag: string): boolean { + return this.shape.tags[this.current]!.has(tag) + } + matches(name: State): boolean { + return this.shape.stateNames[this.current] === name + } + + start = (): void => { + if (this.isRunning) return + this.isRunning = true + this.run(() => this.handle.start()) + if (this.startListeners) for (const fn of this.startListeners) fn() + } + stop = (): void => { + if (!this.isRunning) return + this.isRunning = false + this.run(() => this.handle.stop()) + if (this.stopListeners) for (const fn of this.stopListeners) fn() + } + onStart = (fn: () => void): (() => void) => { + ;(this.startListeners ??= new Set()).add(fn) + if (this.isRunning) fn() // already running — fire immediately so late registrants don't miss it + return () => this.startListeners?.delete(fn) + } + onStop = (fn: () => void): (() => void) => { + ;(this.stopListeners ??= new Set()).add(fn) + return () => this.stopListeners?.delete(fn) + } + + subscribe = (listener: () => void): (() => void) => this.broadcast.add(listener) + + private makeSelection(selector: () => Value): Selection { + return makeSelection(selector, onWake => this.broadcast.add(onWake)) + } + // Built on first access, then reused — the facade is stateless, so one instance serves all reads. + get select(): Select { + if (this.selectFacade) return this.selectFacade + const sel = ((selector: () => Value) => this.makeSelection(selector)) as Select< + State, + Context, + Computed + > + sel.context = (key: K) => this.makeSelection(() => this.ctx[key]) + sel.computed = (key: K) => + this.makeSelection(() => this.computed[key]) + sel.state = () => this.makeSelection(() => this.state) + return (this.selectFacade = sel) + } +} + +/** + * Wrap a Rust machine (an instance of a class exported with + * `dunky_wasm::export_machine!`) in the `Machine` interface, so the connector and every + * target consume it like a machine of the TS engine. Its types are the class's, + * generated from the machine's Rust types. + */ +export function fromWasm( + handle: Handle, + options?: FromWasmOptions['computed']>, +): Machine< + TypesOf['state'], + TypesOf['context'], + TypesOf['event'], + TypesOf['computed'] +> +/** For a class built without generated types: name them. */ +export function fromWasm< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + handle: WasmMachine, + options?: FromWasmOptions, +): Machine +export function fromWasm( + handle: WasmMachine, + options: FromWasmOptions> = {}, +): Machine> { + return new RustMachine>( + shapeOf(handle), + handle, + (options.computed ?? {}) as Record | undefined>, + ) +} diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts new file mode 100644 index 0000000..29286cf --- /dev/null +++ b/packages/wasm/src/index.ts @@ -0,0 +1,9 @@ +export { + fromWasm, + type ComputedMapping, + type FromWasmOptions, + type MachineTypes, + type TypesOf, + type WasmMachine, + type WasmMachineMeta, +} from './from-wasm' diff --git a/packages/wasm/tests/from-wasm.test.ts b/packages/wasm/tests/from-wasm.test.ts new file mode 100644 index 0000000..d34e41e --- /dev/null +++ b/packages/wasm/tests/from-wasm.test.ts @@ -0,0 +1,113 @@ +/** + * `fromWasm` against a scripted handle that plays the Rust side of the protocol: + * events in by kind, a notify per change naming the changed fields, computed values + * served by identity. The facade behavior both machine kinds share (subscribe, select, + * lifecycle, timers) is pinned by the TS-machine tests. + */ +import { describe, expect, it, vi } from 'vitest' +import { fromWasm, type WasmMachine, type WasmMachineMeta } from '../src' + +const FIELDS = Array.from({ length: 40 }, (_, i) => `f${i}`) + +function fake() { + let host: { notify: (state: number, lo: number, hi: number) => number } + // Rust calls the host only during a call into it: a change waits for the next event. + let pending: (() => void) | null = null + const rust = { + state: 0, + values: FIELDS.map(() => 0), + computed: [[2, 0], 'other'] as unknown[], + } + const reads = { field: 0 } + const handle = { + attach: (h: object) => { + host = h as typeof host + return 0 + }, + send: vi.fn((_kind: number, _event: unknown) => { + pending?.() + pending = null + return 0 + }), + start: () => 0, + stop: () => 0, + running: () => false, + state: () => rust.state, + computed: (id: number) => rust.computed[id], + fire: vi.fn(() => 0), + takeFailure: () => undefined, + field: (i: number) => { + reads.field++ + return rust.values[i] + }, + meta: (): WasmMachineMeta => ({ + states: ['idle', 'busy'], + events: ['go', 'set'], + fields: FIELDS, + computed: ['indices', 'other'], + tags: [[], ['working']], + }), + } satisfies WasmMachine + /** On the next event, Rust changes the state and the fields at `changed`. */ + const change = (state: number, changed: Record) => { + pending = () => { + rust.state = state + let lo = 0 + let hi = 0 + for (const [i, value] of Object.entries(changed)) { + rust.values[Number(i)] = value + if (Number(i) < 32) lo |= 1 << Number(i) + else hi |= 1 << (Number(i) - 32) + } + host.notify(state, lo >>> 0, hi >>> 0) + } + } + return { handle, rust, reads, change } +} + +type Ctx = Record + +describe('fromWasm', () => { + it('sends events by kind, with the event object for the payload; ignores unknown types', () => { + const { handle } = fake() + const m = fromWasm<'idle' | 'busy', Ctx, { type: string; v?: number }>(handle) + m.send({ type: 'go' }) + m.send({ type: 'set', v: 1 }) + m.send({ type: 'nope' }) + expect(handle.send.mock.calls.map(([kind, event]) => [kind, event])).toEqual([ + [0, { type: 'go' }], + [1, { type: 'set', v: 1 }], + ]) + }) + + it('moves the state on each notify and re-reads only the fields it names', () => { + const { handle, reads, change } = fake() + const m = fromWasm<'idle' | 'busy', Ctx, { type: 'go' }>(handle) + const context = m.context + const listener = vi.fn(() => [m.state, m.context.f1, m.context.f35]) + m.subscribe(listener) + reads.field = 0 + change(1, { 1: 7, 35: 9 }) + m.send({ type: 'go' }) + expect(listener).toHaveReturnedWith(['busy', 7, 9]) + expect(m.hasTag('working')).toBe(true) + expect(reads.field).toBe(2) + expect(m.context).toBe(context) + }) + + it('maps a computed value only when Rust serves a new one; `from` reads another value', () => { + const { handle, rust } = fake() + const own = ['x', 'y', 'z'] + const map = vi.fn((raw: unknown) => (raw as number[]).map(i => own[i]!)) + const m = fromWasm<'idle', Ctx, { type: 'go' }, { indices: string[]; other: string[] }>( + handle, + { computed: { indices: map, other: { from: 'indices', map } } }, + ) + expect(m.computed.indices).toEqual(['z', 'x']) + expect(m.computed.indices).toBe(m.computed.indices) + expect(map).toHaveBeenCalledTimes(1) + rust.computed[0] = [1] + expect(m.computed.indices).toEqual(['y']) + expect(m.computed.other).toEqual(['y']) + }) +}) diff --git a/packages/wasm/tsconfig.json b/packages/wasm/tsconfig.json new file mode 100644 index 0000000..3fd6989 --- /dev/null +++ b/packages/wasm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "tests"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d1998c..111dde0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,12 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.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)) + packages/wasm: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:* + version: link:../core + sandbox/native: dependencies: '@dunky.dev/native-state-machine': @@ -289,6 +295,9 @@ importers: '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm '@opentui/core': specifier: ^0.4.1 version: 0.4.1(typescript@7.0.2)(web-tree-sitter@0.25.10) @@ -323,6 +332,9 @@ importers: '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm '@sandbox/cmdk-core': specifier: workspace:^ version: link:../shared @@ -354,6 +366,9 @@ importers: '@dunky.dev/state-machine-bindings': specifier: workspace:^ version: link:../../packages/shared/bindings + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm sandbox/solid: dependencies: @@ -369,6 +384,9 @@ importers: '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm '@sandbox/cmdk-core': specifier: workspace:^ version: link:../shared diff --git a/sandbox/README.md b/sandbox/README.md index 4f45dc5..0880db2 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -38,8 +38,25 @@ pnpm -C sandbox/solid dev pnpm -C sandbox/opentui dev # Native — needs Expo + an iOS/Android simulator or device. -pnpm -C sandbox/native start # then press i / a, or scan the QR +pnpm -C sandbox/native dev # then press i / a, or scan the QR ``` All four consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. + +## The same palette, written in Rust + +Both palettes run on the Rust engine. By default the sandboxes run the one written in +TypeScript (`shared/src/machine.ts`); each web and terminal sandbox can also run its +Rust twin (`crates/sandbox`, wrapped with `fromWasm` from `@dunky.dev/state-machine-wasm`) — same `connect()`, same markup, +only the machine's source language differs: + +```bash +pnpm build:wasm # once: Rust → wasm (see CONTRIBUTING.md) +pnpm -C sandbox/react dev # then open http://localhost:5173/?machine=rust +pnpm -C sandbox/solid dev # then open http://localhost:5173/?machine=rust +pnpm -C sandbox/opentui dev:rust # Bun runs the wasm +``` + +React Native has no WebAssembly (Hermes), so the native sandbox waits for the engine +over JSI. diff --git a/sandbox/opentui/package.json b/sandbox/opentui/package.json index d80f031..106d266 100644 --- a/sandbox/opentui/package.json +++ b/sandbox/opentui/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run src/index.tsx" + "dev": "bun run src/index.tsx", + "dev:rust": "bun run src/index.tsx --machine=rust" }, "dependencies": { "@dunky.dev/opentui-state-machine": "workspace:^", @@ -12,6 +13,7 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^", "@opentui/core": "^0.4.1", "@opentui/react": "^0.4.1", "@sandbox/cmdk-core": "workspace:^", diff --git a/sandbox/opentui/src/app.tsx b/sandbox/opentui/src/app.tsx index 1cf0a55..dfc0b36 100644 --- a/sandbox/opentui/src/app.tsx +++ b/sandbox/opentui/src/app.tsx @@ -1,8 +1,11 @@ import { useState } from 'react' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' + +export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -22,7 +25,8 @@ export function App() { NAMED ANSI color ('green'), which the terminal maps to its own palette. */} ⌘K Command Pallete - setLast(c.label)} /> + setLast(c.label)} /> + Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} One state machine drives this ⌘K palette. diff --git a/sandbox/opentui/src/command-palette.tsx b/sandbox/opentui/src/command-palette.tsx index f8ae000..8d215ed 100644 --- a/sandbox/opentui/src/command-palette.tsx +++ b/sandbox/opentui/src/command-palette.tsx @@ -7,9 +7,9 @@ import type { KeyEvent } from '@opentui/core' import { useMachine } from '@dunky.dev/react-state-machine' import { normalize } from '@dunky.dev/opentui-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -54,8 +54,12 @@ const paletteKeys = (machine: CommandPaletteMachine) => (key: KeyEvent) => { // the SAME shared machine, `connect` produces the SAME logical bindings — only // `normalize` (from the opentui package) and the JSX elements differ. The index // math, filtering, and selection all live in the shared machine, unchanged. -export function CommandPalette(props: CommandPaletteProps) { - const { api, machine } = useMachine(commandPaletteMachineConfig, connectCommandPalette, [], props) +// `source` is the TS config or the Rust machine (see index.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api, machine } = useMachine(source, connectCommandPalette, [], props) // Starts closed — press Ctrl+K to open. (⌘K can't be used in a terminal: // macOS/Ghostty don't forward Cmd to the program — Cmd is an app/OS modifier — diff --git a/sandbox/opentui/src/index.tsx b/sandbox/opentui/src/index.tsx index 45724b3..bc7f11d 100644 --- a/sandbox/opentui/src/index.tsx +++ b/sandbox/opentui/src/index.tsx @@ -1,6 +1,19 @@ import { createCliRenderer } from '@opentui/core' import { createRoot } from '@opentui/react' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Language } from './app' + +// `--machine=rust` runs the palette written in Rust (crates/sandbox → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = process.argv.includes('--machine=rust') ? 'rust' : 'ts' + +let source: CommandPaletteSource = commandPaletteMachineConfig +if (language === 'rust') { + // Imported only for the Rust machine, so the TS app loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust-node') + rust.loadRustNode() + source = props => rust.createRustPalette(props.commands) +} const renderer = await createCliRenderer() -createRoot(renderer).render() +createRoot(renderer).render() diff --git a/sandbox/react/package.json b/sandbox/react/package.json index ffa4d77..de98d4d 100644 --- a/sandbox/react/package.json +++ b/sandbox/react/package.json @@ -13,6 +13,7 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^", "@sandbox/cmdk-core": "workspace:^", "react": "^19.2.6", "react-dom": "^19.2.6" diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 38b4005..3603605 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -1,8 +1,11 @@ import { useState } from 'react' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' + +export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -10,6 +13,7 @@ export function App() {

⌘K Command Palette


{ setLast(c.label) @@ -25,6 +29,12 @@ export function App() {

Last selected: {last}

+

+ Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} + + Switch to {language === 'rust' ? 'TypeScript' : 'Rust'} + +

) } diff --git a/sandbox/react/src/command-palette.tsx b/sandbox/react/src/command-palette.tsx index 4b2746a..924a1d4 100644 --- a/sandbox/react/src/command-palette.tsx +++ b/sandbox/react/src/command-palette.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/react-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -28,13 +28,12 @@ const cmdkShortcut: ComponentEffect // machine, `connect` produces logical bindings, and `normalize` turns them into // DOM props (onPress→onClick, role/aria-*, etc). The component is just markup; // the look lives in the stylesheet shared with the Solid app. -export function CommandPalette(props: CommandPaletteProps) { - const { api } = useMachine( - commandPaletteMachineConfig, - connectCommandPalette, - [cmdkShortcut], - props, - ) +// `source` is the TS config or the Rust machine (see main.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api } = useMachine(source, connectCommandPalette, [cmdkShortcut], props) const inputRef = useRef(null) // Focus the input whenever the palette opens (a renderer concern, not the diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 4950347..d1c148f 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Language } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -8,8 +9,24 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -createRoot(root).render( - - - , -) +// `?machine=rust` runs the palette written in Rust (crates/sandbox → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = + new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' + +async function boot(target: HTMLElement) { + let source: CommandPaletteSource = commandPaletteMachineConfig + if (language === 'rust') { + // Imported only for the Rust machine, so the TS page loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust') + await rust.loadRust() + source = props => rust.createRustPalette(props.commands) + } + createRoot(target).render( + + + , + ) +} + +void boot(root) diff --git a/sandbox/react/vite.config.ts b/sandbox/react/vite.config.ts index 26d9620..97bc9ea 100644 --- a/sandbox/react/vite.config.ts +++ b/sandbox/react/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ resolve: { alias: { '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), '@dunky.dev/react-state-machine': resolve(__dirname, '../../packages/react/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), diff --git a/sandbox/shared/package.json b/sandbox/shared/package.json index 425d0d2..fa6f570 100644 --- a/sandbox/shared/package.json +++ b/sandbox/shared/package.json @@ -6,10 +6,13 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./rust": "./src/rust.ts", + "./rust-node": "./src/rust-node.ts" }, "peerDependencies": { "@dunky.dev/state-machine": "workspace:^", - "@dunky.dev/state-machine-bindings": "workspace:^" + "@dunky.dev/state-machine-bindings": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^" } } diff --git a/sandbox/shared/src/index.ts b/sandbox/shared/src/index.ts index 2ae22e0..ea943d8 100644 --- a/sandbox/shared/src/index.ts +++ b/sandbox/shared/src/index.ts @@ -9,4 +9,5 @@ export type { CommandPaletteComputed, CommandPaletteState, CommandPaletteProps, + CommandPaletteSource, } from './types' diff --git a/sandbox/shared/src/rust-node.ts b/sandbox/shared/src/rust-node.ts new file mode 100644 index 0000000..6fc58b9 --- /dev/null +++ b/sandbox/shared/src/rust-node.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import { loadRustSync } from './rust' + +/** Load the sandbox wasm in Node or Bun from the built package. */ +export function loadRustNode(): void { + loadRustSync( + readFileSync(new URL('../../../crates/sandbox/pkg/dunky_sandbox_bg.wasm', import.meta.url)), + ) +} + +export * from './rust' diff --git a/sandbox/shared/src/rust.ts b/sandbox/shared/src/rust.ts new file mode 100644 index 0000000..0c55acb --- /dev/null +++ b/sandbox/shared/src/rust.ts @@ -0,0 +1,65 @@ +/** + * The sandbox machines written in Rust (crates/sandbox), wrapped with `fromWasm`. + * Build the wasm first: `pnpm build:wasm`. Load it once — `await loadRust()` in a + * browser, `loadRustSync(bytes)` (or `./rust-node`) in Node and Bun — then build + * machines with the factories below. Their types come from the Rust types: the build + * generates them onto each class, and `fromWasm` reads them. + */ +import { fromWasm, type TypesOf } from '@dunky.dev/state-machine-wasm' +import init, { + DialogMachine, + initSync, + PaletteMachine, +} from '../../../crates/sandbox/pkg/dunky_sandbox.js' +import type { CommandPaletteMachine } from './machine' +import type { + Command, + CommandPaletteComputed, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteState, +} from './types' + +/** Load the wasm in a browser (fetches `dunky_sandbox_bg.wasm` next to the glue). */ +export async function loadRust(input?: Parameters[0]): Promise { + await init(input) +} + +/** Load the wasm synchronously from its bytes (Node, Bun, tests, workers). */ +export function loadRustSync(bytes: BufferSource): void { + initSync({ module: bytes }) +} + +/** The command palette. `results` crosses the boundary as indices and maps back onto + * the caller's own command objects: Rust owns the ids, rich values stay in JS. */ +export function createRustPalette(commands: Command[]): CommandPaletteMachine { + return fromWasm(new PaletteMachine(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as ArrayLike, i => commands[i]!), + }, + }, + }) +} + +/** A dialog whose `closing` phase lasts `exitMs` (an `after` timer on the host clock). */ +export function createRustDialog(exitMs: number) { + return fromWasm(new DialogMachine(exitMs)) +} + +export type DialogState = TypesOf['state'] +export type DialogContext = TypesOf['context'] +export type DialogEvent = TypesOf['event'] + +// The Rust palette's generated types must be the TS palette's, exactly: `pnpm typecheck` +// fails here as soon as one side changes without the other. +type Same = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Expect = T +type RustPalette = TypesOf +export type RustPaletteMatchesTs = [ + Expect>, + Expect>, + Expect>, + Expect>, +] diff --git a/sandbox/shared/src/types.ts b/sandbox/shared/src/types.ts index 46828ed..d833165 100644 --- a/sandbox/shared/src/types.ts +++ b/sandbox/shared/src/types.ts @@ -1,3 +1,5 @@ +import type { MachineSource } from '@dunky.dev/state-machine' + // ============================================================================= // cmdk — shared types // @@ -59,3 +61,14 @@ export interface CommandPaletteProps { /** Fired whenever the palette opens or closes. */ onOpenChange?: (open: boolean) => void } + +/** Where a renderer gets its palette machine: the TS config, or a ready machine (e.g. the + * Rust palette via wasm). Both seed from the same props and speak the same events. */ +export type CommandPaletteSource = ( + props: CommandPaletteProps, +) => MachineSource< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed +> diff --git a/sandbox/shared/tests/rust.test.ts b/sandbox/shared/tests/rust.test.ts new file mode 100644 index 0000000..72c44e4 --- /dev/null +++ b/sandbox/shared/tests/rust.test.ts @@ -0,0 +1,94 @@ +/** + * The palette written in Rust (through wasm + `fromWasm`) must behave exactly like the + * TS palette for the same event sequence — state, context, computed, and the + * notifications. Needs the wasm build: `pnpm build:wasm`. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { machine } from '@dunky.dev/state-machine' +import { commandPaletteMachineConfig, DEMO_COMMANDS, type CommandPaletteEvent } from '../src' +import { createRustDialog, createRustPalette, loadRustNode } from '../src/rust-node' + +beforeAll(() => loadRustNode()) + +const script: CommandPaletteEvent[] = [ + { type: 'open' }, + { type: 'move', to: 'down' }, + { type: 'move', to: 'down' }, + { type: 'query.set', query: 'go' }, + { type: 'move', to: 'up' }, + { type: 'highlight', index: 99 }, + { type: 'query.set', query: 'zzz' }, + { type: 'move', to: 'down' }, + { type: 'execute' }, + { type: 'query.set', query: 'theme' }, + { type: 'move', to: 'last' }, + { type: 'execute' }, + { type: 'open' }, + { type: 'move', to: 'first' }, + { type: 'execute' }, + { type: 'close' }, +] + +const snapshot = (m: ReturnType) => ({ + state: m.state, + query: m.context.query, + activeIndex: m.context.activeIndex, + lastExecuted: m.context.lastExecuted, + results: m.computed.results.map(c => c.id), + activeId: m.computed.activeId, +}) + +describe('Rust palette', () => { + it('matches the TS palette step by step, notification for notification', () => { + const ts = machine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) + const rs = createRustPalette(DEMO_COMMANDS) + const seen = { ts: [] as unknown[], rs: [] as unknown[] } + ts.subscribe(() => seen.ts.push(snapshot(ts))) + rs.subscribe(() => seen.rs.push(snapshot(rs))) + ts.start() + rs.start() + for (const event of script) { + ts.send(event) + rs.send(event) + expect(snapshot(rs)).toEqual(snapshot(ts)) + } + expect(seen.rs).toEqual(seen.ts) + }) + + it('maps result indices back onto the caller’s own command objects', () => { + const rs = createRustPalette(DEMO_COMMANDS) + rs.send({ type: 'open' }) + expect(rs.computed.results[0]).toBe(DEMO_COMMANDS[0]) + }) + + it('throws on malformed props instead of guessing', () => { + expect(() => + createRustPalette([{ id: 1 } as unknown as (typeof DEMO_COMMANDS)[number]]), + ).toThrow('[machine] bad argument: invalid type') + }) +}) + +describe('Rust dialog', () => { + it('runs the `after` timer on the host clock, and cancels it when the state is left', () => { + vi.useFakeTimers() + try { + const d = createRustDialog(150) + d.start() + d.send({ type: 'open' }) + expect(d.hasTag('visible')).toBe(true) + d.send({ type: 'close' }) + vi.advanceTimersByTime(149) + expect(d.state).toBe('closing') + vi.advanceTimersByTime(1) + expect(d.state).toBe('closed') + expect(d.context.openCount).toBe(1) + + d.send({ type: 'open' }) + d.send({ type: 'close' }) + d.send({ type: 'open' }) // back to open before the exit delay ends + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json index e9ac5b6..43d6d6a 100644 --- a/sandbox/solid/package.json +++ b/sandbox/solid/package.json @@ -13,6 +13,7 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^", "@sandbox/cmdk-core": "workspace:^", "@solidjs/web": "^2.0.0-rc.1", "solid-js": "^2.0.0-rc.1" diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 3d9110c..21c879a 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -1,8 +1,11 @@ import { createSignal } from 'solid-js' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' + +export function App(props: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = createSignal('—') return ( @@ -10,6 +13,7 @@ export function App() {

⌘K Command Palette


{ setLast(c.label) @@ -25,6 +29,12 @@ export function App() {

Last selected: {last()}

+

+ Machine written in {props.language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} + + Switch to {props.language === 'rust' ? 'TypeScript' : 'Rust'} + +

) } diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx index 74a5b1b..12a815e 100644 --- a/sandbox/solid/src/command-palette.tsx +++ b/sandbox/solid/src/command-palette.tsx @@ -1,9 +1,9 @@ import { createEffect, For, Show } from 'solid-js' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -27,13 +27,10 @@ const cmdkShortcut: ComponentEffect // machine and `normalize` maps the logical bindings to DOM props. The // component is just markup; the look lives in the stylesheet shared with the // React app. -export function CommandPalette(props: CommandPaletteProps) { - const { api } = useMachine( - commandPaletteMachineConfig, - connectCommandPalette, - [cmdkShortcut], - props, - ) +// `source` is the TS config or the Rust machine (see main.tsx), read once: the machine +// is built once, like any initial-only prop. +export function CommandPalette(props: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api } = useMachine(props.source, connectCommandPalette, [cmdkShortcut], props) let inputEl: HTMLInputElement | undefined diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index ed52a73..b346627 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -1,5 +1,6 @@ import { render } from '@solidjs/web' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Language } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -7,4 +8,20 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -render(() => , root) +// `?machine=rust` runs the palette written in Rust (crates/sandbox → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = + new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' + +async function boot(target: HTMLElement) { + let source: CommandPaletteSource = commandPaletteMachineConfig + if (language === 'rust') { + // Imported only for the Rust machine, so the TS page loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust') + await rust.loadRust() + source = props => rust.createRustPalette(props.commands) + } + render(() => , target) +} + +void boot(root) diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index eea5af4..23eb478 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ dedupe: ['solid-js', '@solidjs/web'], alias: { '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), '@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..c20a725 --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build the wasm of the sandbox machines written in Rust (crates/sandbox -> its pkg/), +# with their TS types: what `?machine=rust` and the sandbox's Rust tests load. +# Needs: rustup target wasm32-unknown-unknown, wasm-bindgen-cli matching the crate +# version (see Cargo.toml), and optionally wasm-opt (binaryen) for the size pass. +set -euo pipefail +cd "$(dirname "$0")/.." +# The features rustc enables by default for wasm32 (Rust >= 1.82); binaryen must accept them. +WASM_OPT_FLAGS=(-Oz --strip-debug --strip-producers + --enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int + --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue) + +# build [cargo args...] +build() { + local crate=$1 lib=$2 out=$3 + shift 3 + cargo build -p "$crate" --target wasm32-unknown-unknown --profile wasm "$@" + # --target web: one ES module for browsers (init) and Node/Bun/tests (initSync). + # --weak-refs: instances are freed by a FinalizationRegistry. + # --reference-types: JS values cross as externrefs, so passing one costs no JS call. + wasm-bindgen --target web --weak-refs --reference-types --out-dir "$out" "target/wasm32-unknown-unknown/wasm/$lib.wasm" + if command -v wasm-opt >/dev/null 2>&1; then + wasm-opt "${WASM_OPT_FLAGS[@]}" "$out/${lib}_bg.wasm" -o "$out/${lib}_bg.wasm" + else + echo "wasm-opt not found: skipping the size pass (brew install binaryen)" >&2 + fi + local raw gz + raw=$(wc -c < "$out/${lib}_bg.wasm" | tr -d ' ') + gz=$(gzip -9c "$out/${lib}_bg.wasm" | wc -c | tr -d ' ') + echo "$crate: ${raw} B raw, ${gz} B gzip" +} + +# types <.d.ts> [cargo args...] — a Rust machine's TS types come from its Rust +# types: the crate's `typescript` example prints them for each exported class, and they +# join the class wasm-bindgen declared, where fromWasm reads them. +types() { + local crate=$1 dts=$2 + shift 2 + cargo run -q -p "$crate" --example typescript "$@" >> "$dts" +} + +build dunky-sandbox dunky_sandbox crates/sandbox/pkg --features wasm +types dunky-sandbox crates/sandbox/pkg/dunky_sandbox.d.ts --features wasm diff --git a/tsconfig.build.json b/tsconfig.build.json index ba5a59a..220594f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,7 @@ "references": [ { "path": "./tsconfig.json" }, { "path": "./packages/core" }, + { "path": "./packages/wasm" }, { "path": "./packages/dom" }, { "path": "./packages/react" }, { "path": "./packages/solid" }, @@ -10,6 +11,7 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, - { "path": "./benchmark" } + { "path": "./benchmark" }, + { "path": "./sandbox/shared" } ] } diff --git a/tsconfig.json b/tsconfig.json index 8436124..0f11fdb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "strict": true, "paths": { "@dunky.dev/state-machine": ["./packages/core/src"], + "@dunky.dev/state-machine-wasm": ["./packages/wasm/src"], "@dunky.dev/react-state-machine": ["./packages/react/src"], "@dunky.dev/solid-state-machine": ["./packages/solid/src"], "@dunky.dev/native-state-machine": ["./packages/native/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 937d196..51d9e8b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ // choice. Keep in sync with the publish set in .changeset/config.json. workspace: [ 'packages/core', + 'packages/wasm', 'packages/dom', 'packages/react', 'packages/solid', diff --git a/vitest.config.ts b/vitest.config.ts index 117b2dc..5940b5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ name: 'default', globals: false, environment: 'node', - include: ['packages/**/tests/**/*.test.{ts,tsx}'], + include: ['packages/**/tests/**/*.test.{ts,tsx}', 'sandbox/shared/tests/**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], }, }, From 129ec70f110e7c8326b997a2fdc80bd7ec7a4194 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 13:13:19 +0200 Subject: [PATCH 24/28] =?UTF-8?q?docs(website):=20the=20Rust=20engine=20?= =?UTF-8?q?=E2=80=94=20home=20section=20and=20a=20Rust=20docs=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- website/astro.config.ts | 8 + website/src/content/docs/install.mdx | 10 ++ website/src/content/docs/rust/index.mdx | 56 +++++++ website/src/content/docs/rust/machines.mdx | 119 +++++++++++++++ website/src/content/docs/rust/typescript.mdx | 94 ++++++++++++ website/src/pages/index.astro | 147 +++++++++++++++++++ 6 files changed, 434 insertions(+) create mode 100644 website/src/content/docs/rust/index.mdx create mode 100644 website/src/content/docs/rust/machines.mdx create mode 100644 website/src/content/docs/rust/typescript.mdx diff --git a/website/astro.config.ts b/website/astro.config.ts index 20f7ef6..e503409 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -133,6 +133,14 @@ export default defineConfig({ { label: 'OpenTUI', link: 'libs/opentui' }, ], }, + { + label: 'Rust', + items: [ + { label: 'Two engines', link: 'rust' }, + { label: 'Machines in Rust', link: 'rust/machines' }, + { label: 'Use in TypeScript', link: 'rust/typescript' }, + ], + }, { label: 'Reference', items: [ diff --git a/website/src/content/docs/install.mdx b/website/src/content/docs/install.mdx index 85cef64..25b129d 100644 --- a/website/src/content/docs/install.mdx +++ b/website/src/content/docs/install.mdx @@ -25,3 +25,13 @@ toggle.start() toggle.send({ type: 'flip' }) toggle.state // 'on' ``` + +## In Rust + +The same machines exist in Rust, on a second engine that follows the same spec. See +[Two engines](/rust) and [Machines in Rust](/rust/machines): + +```toml +[dependencies] +dunky-core = { git = "https://github.com/dunky-dev/state-machine" } +``` diff --git a/website/src/content/docs/rust/index.mdx b/website/src/content/docs/rust/index.mdx new file mode 100644 index 0000000..dd5cc02 --- /dev/null +++ b/website/src/content/docs/rust/index.mdx @@ -0,0 +1,56 @@ +--- +title: Two engines +description: Dunky's state machines exist twice, in TypeScript and in Rust, and both follow one spec. +--- + +Dunky's state machines are implemented **twice**: in TypeScript +(`@dunky.dev/state-machine`) and in Rust (`dunky-core`). Neither engine runs on the +other. Each is tuned for its own runtime, and both follow one behavior contract: +[`packages/core/SPEC.md`](https://github.com/dunky-dev/state-machine/blob/main/packages/core/SPEC.md). + +``` + packages/core/SPEC.md + (the one behavior contract) + / \ + v v +TypeScript engine Rust engine +@dunky.dev/state-machine dunky-core +JS and every target Rust programs, native bindings, + and JS through wasm +``` + +## Which one to use + +- **Building a JS app** (React, Solid, React Native, OpenTUI): write your machines in + **TypeScript**. That is the default, and the fastest path in JS: the context lives in + JS, so nothing crosses a boundary. +- **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. + +## 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). | + +The crates are not on crates.io yet. Use them from the repository: + +```toml +[dependencies] +dunky-core = { git = "https://github.com/dunky-dev/state-machine" } +``` + +## How the two stay in sync + +- A behavior change updates the spec, both engines and both test suites in the same + pull request. A change that lands in one engine only is not done. +- The Rust tests are twins of the TS tests, file by file: a new behavior test on one + side gets its twin on the other. +- What only one engine has stays out of the shared spec: TS-only typing (`setup()`), + and Rust-only authoring (derives, timers as commands). diff --git a/website/src/content/docs/rust/machines.mdx b/website/src/content/docs/rust/machines.mdx new file mode 100644 index 0000000..c5c2f57 --- /dev/null +++ b/website/src/content/docs/rust/machines.mdx @@ -0,0 +1,119 @@ +--- +title: Machines in Rust +description: Write a Dunky machine in Rust with dunky-core. +--- + +`dunky-core` has the same machine model as the TS engine: flat states, +run-to-completion, guards, actions, effects, `after` timers, computed values, watchers +and subscriptions. The difference is the shape: states, events and context are Rust +types, and the config is built once with a builder. + +```toml +[dependencies] +dunky-core = { git = "https://github.com/dunky-dev/state-machine" } +``` + +## Types + +A machine names three types, and the derives generate the glue: + +```rust +use dunky_core::{Config, Context, Event, Machine, State, Types}; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum DialogState { Closed, Open, Closing } + +#[derive(Event, Clone, Debug)] +enum DialogEvent { Open, Close, Toggle } + +#[derive(Context, Clone, Debug, PartialEq)] +struct DialogCtx { exit_ms: u32, open_count: u32 } + +struct Dialog; +impl Types for Dialog { + type State = DialogState; + type Event = DialogEvent; + type Context = DialogCtx; +} +``` + +- `State`: one name per variant (`Closing` → `"closing"`). +- `Event`: one kind per variant (`DialogEventKind::Open`, …), which keys the handlers. A + variant can carry a payload. +- `Context`: a patch builder (`DialogCtx::patch().open_count(1)`), one `Field` per + field, and tracked reads for computed values. + +## Config + +Build the config once; every machine of the type shares it. + +```rust +fn config(exit_ms: u32) -> Config { + use DialogEventKind as E; + use DialogState::{Closed, Closing, Open}; + let mut b = Config::::builder(Closed, DialogCtx { exit_ms, open_count: 0 }); + b.delay("exitDelay", |g| g.context().exit_ms); + b.action("countOpen", |p| { + let n = p.context().open_count + 1; + p.set_context(DialogCtx::patch().open_count(n)); + }); + b.state(Closed, |s| s.on(E::Open, |t| t.target(Open).action("countOpen"))); + b.state(Open, |s| s.tag("visible").on(E::Close, |t| t.target(Closing))); + b.state(Closing, |s| { + s.tag("visible") + .after("exitDelay", |t| t.target(Closed)) + .on(E::Open, |t| t.target(Open).action("countOpen")) + }); + b.build() +} +``` + +Guards, actions, effects and delays are inline closures or registered names +(`b.guard`, `b.action`, `b.effect`, `b.delay`); a name resolves when the config is +built. + +## Running it + +```rust +let dialog = Machine::new(&config(200)); +dialog.start(); +dialog.send(DialogEvent::Open); +assert_eq!(dialog.state(), DialogState::Open); +assert!(dialog.has_tag("visible")); +assert_eq!(dialog.context().open_count, 1); + +let _sub = dialog.subscribe(|| println!("changed")); +``` + +## Timers are commands + +The engine owns no clock. An `after` timer is a **command** your host runs: take the +commands after each call, schedule them on your event loop, and report each due timer. + +```rust +for command in dialog.take_commands() { + // Command::StartTimer { id, ms }: call dialog.fire_timer(id) after ms. + // Command::CancelTimer { id }: drop it. +} +``` + +In tests, `dunky_core::testing::Clock` plays the host with virtual time: + +```rust +let mut clock = dunky_core::testing::Clock::new(&dialog); +dialog.send(DialogEvent::Close); // -> closing, schedules exitDelay +clock.sync(); +clock.advance(200); +assert_eq!(dialog.state(), DialogState::Closed); +``` + +## Computed values + +```rust +let label = b.computed("label", |c| format!("opened {} times", c.context.open_count())); +// later, on a machine: +let text = machine.computed(label); +``` + +A computed value is lazy and memoized: it reruns only when a field, a state or a computed +value it read has changed. diff --git a/website/src/content/docs/rust/typescript.mdx b/website/src/content/docs/rust/typescript.mdx new file mode 100644 index 0000000..7b7e37b --- /dev/null +++ b/website/src/content/docs/rust/typescript.mdx @@ -0,0 +1,94 @@ +--- +title: Use in TypeScript +description: Run a machine written in Rust from TypeScript, through wasm. +--- + +import Install from '../../../components/install.astro' + + + +A machine written in Rust can run in a JS app, next to your TS machines. It still runs +entirely on the Rust engine, inside its own wasm module; `fromWasm` puts it behind the +same `Machine` interface the TS engine returns, so `connect()`, `useMachine` and every +target take it unchanged. + +## 1. Export the machine + +In your Rust crate, depend on `dunky-core`, `dunky-wasm` and `wasm-bindgen`, and export +the machine type as a JS class: + +```rust +dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands).map(|c| Machine::with_context(&config(), palette::context(c))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } + // Optional: served too, but left out of the machine's TS types. + internal { palette::RESULT_INDICES => Vec } +} +``` + +The event type derives `#[event(deserialize)]` and the context `#[context(serialize)]`, +so values can cross. Data types your context, events or computed values hold derive +`TsType` next to their serde derives. + +## 2. Build it, with its types + +```bash +cargo build --target wasm32-unknown-unknown --release +wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg \ + target/wasm32-unknown-unknown/release/my_machines.wasm +``` + +Then add the class's TS types: natively, `PaletteMachine::typescript(&config)` returns +them, generated from the Rust types. Print them from a small example binary onto the +`.d.ts`: + +```bash +cargo run --example typescript >> pkg/my_machines.d.ts +``` + +## 3. Use it + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/my_machines.js' + +await init() +// Machine<'closed' | 'open', { commands: …; query: string; … }, …>: typed from Rust. +const palette = fromWasm(new PaletteMachine(commands)) +palette.start() +palette.send({ type: 'query.set', query: 'git' }) +``` + +In a target, return it from the first argument of `useMachine`, where a config would go: + +```tsx +useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props) +``` + +The machine must come fresh from that function (one per component): the hook owns its +lifecycle, as it does for a config. + +## Mapping computed values + +The `computed` option maps a raw value once per change. For example, results that +crossed the boundary as indices go back onto your own objects: + +```ts +fromWasm(new PaletteMachine(commands), { + computed: { + results: { from: 'resultIndices', map: raw => Array.from(raw as number[], i => commands[i]!) }, + }, +}) +``` + +## What it costs + +In JS, a machine written in TypeScript is a little faster: its context already lives in +JS, while a Rust machine refreshes a JS mirror of its context on each change. Write a +machine in Rust to **reuse** it: the same machine runs in Rust programs and on React +Native. diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index e14d333..69f8ee9 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -697,6 +697,52 @@ const shareOnX = `https://twitter.com/intent/tweet?text=${encodeURIComponent(sha + +
+
+

Two engines · one spec

+

+ Write it in TypeScript.
Or in Rust. +

+

+ The same machines exist twice: a TypeScript engine for JS and every + target, and a Rust engine for Rust programs and native bindings. Both + follow one behavior contract, with twin test suites, and neither runs + on the other, so each stays tuned for its own runtime. +

+
+
+

TypeScript · @dunky.dev/state-machine

+
{`const dialog = machine({
+  initial: 'closed',
+  context: { openCount: 0 },
+  states: {
+    closed: { on: { open: { target: 'open' } } },
+    open: { on: { close: { target: 'closed' } } },
+  },
+})`}
+
+
+

Rust · dunky-core

+
{`let mut b = Config::::builder(
+    Closed, DialogCtx { open_count: 0 });
+b.state(Closed, |s| s.on(E::Open,
+    |t| t.target(Open)));
+b.state(Open, |s| s.on(E::Close,
+    |t| t.target(Closed)));
+let dialog = Machine::new(&b.build());`}
+
+
+

+ A machine written in Rust also runs in a JS app through wasm, behind + the same Machine interface, so every target takes it. +

+ How the two engines work → +
+
+
@@ -1643,6 +1689,107 @@ const shareOnX = `https://twitter.com/intent/tweet?text=${encodeURIComponent(sha opacity: 0.7; } + /* ==================================================================== + TWO ENGINES SECTION + ==================================================================== */ + .engines-section { + background-color: var(--color-bg); + padding: 6rem 1.5rem; + border-top: 1px solid var(--color-border); + } + .engines-inner { + max-width: 64rem; + margin: 0 auto; + } + .engines-eyebrow { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--color-accent); + margin: 0 0 1.25rem; + } + .engines-heading { + font-size: 2.5rem; + font-weight: 800; + line-height: 1.08; + letter-spacing: -0.02em; + color: var(--color-fg); + margin: 0 0 1.25rem; + } + @media (min-width: 640px) { + .engines-heading { + font-size: 3rem; + } + } + .engines-heading-alt { + color: var(--color-accent); + } + .engines-sub { + font-size: 1.0625rem; + line-height: 1.75; + color: var(--color-muted); + max-width: 42rem; + margin: 0 0 2.5rem; + } + .engines-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + margin-bottom: 2rem; + } + @media (min-width: 768px) { + .engines-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .engine-card { + border: 1px solid var(--color-border); + border-top-width: 3px; + border-radius: 0.75rem; + padding: 1.25rem; + background: var(--color-panel); + min-width: 0; + } + .engine-card-ts { + border-top-color: var(--color-accent); + } + .engine-card-rust { + border-top-color: #22c5d3; + } + .engine-label { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--color-fg); + margin: 0 0 0.75rem; + } + .engine-code { + margin: 0; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + color: var(--color-fg); + background: transparent; + } + .engines-note { + font-size: 0.95rem; + line-height: 1.7; + color: var(--color-muted); + margin: 0 0 1.5rem; + } + .engines-cta { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; + } + .engines-cta:hover { + opacity: 0.7; + } + /* ==================================================================== CONTRIBUTE SECTION ==================================================================== */ From 93be8867342cd03f4a6c8249ab13712978814431 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 14:36:52 +0200 Subject: [PATCH 25/28] perf(benchmark): one suite per engine, benchmark/ts and benchmark/rust - 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 --- .claude/skills/benchmark/SKILL.md | 17 +- .github/workflows/deploy-benchmark.yml | 4 +- AGENTS.md | 2 +- CONTRIBUTING.md | 11 +- Cargo.lock | 7 + Cargo.toml | 2 +- README.md | 4 +- TODO.md | 4 +- benchmark/README.md | 299 +---------- benchmark/rust/Cargo.toml | 14 + benchmark/rust/README.md | 66 +++ benchmark/rust/benches/engine.rs | 210 ++++++++ benchmark/rust/src/lib.rs | 512 +++++++++++++++++++ benchmark/{ => ts}/CHANGELOG.md | 0 benchmark/ts/README.md | 294 +++++++++++ benchmark/{ => ts}/competitors.ts | 0 benchmark/{ => ts}/demo/CHANGELOG.md | 0 benchmark/{ => ts}/demo/README.md | 2 +- benchmark/{ => ts}/demo/index.html | 0 benchmark/{ => ts}/demo/package.json | 0 benchmark/{ => ts}/demo/src/app.tsx | 0 benchmark/{ => ts}/demo/src/engines.ts | 0 benchmark/{ => ts}/demo/src/feed.ts | 0 benchmark/{ => ts}/demo/src/main.tsx | 0 benchmark/{ => ts}/demo/src/metrics.ts | 0 benchmark/{ => ts}/demo/src/panels.tsx | 0 benchmark/{ => ts}/demo/vite.config.ts | 2 +- benchmark/{ => ts}/index.ts | 2 +- benchmark/{ => ts}/package.json | 0 benchmark/{ => ts}/report.ts | 0 benchmark/{ => ts}/tests/compose.ts | 0 benchmark/{ => ts}/tests/computed.ts | 0 benchmark/{ => ts}/tests/construct.ts | 0 benchmark/{ => ts}/tests/engine.ts | 0 benchmark/{ => ts}/tests/fan-out.ts | 0 benchmark/{ => ts}/tests/memory.ts | 0 benchmark/{ => ts}/tests/rendering/bench.tsx | 0 benchmark/{ => ts}/tests/rendering/run.ts | 0 benchmark/{ => ts}/tsconfig.json | 10 +- package.json | 4 +- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 4 +- tsconfig.build.json | 2 +- website/package.json | 2 +- website/src/content/docs/rust/index.mdx | 8 + 45 files changed, 1164 insertions(+), 328 deletions(-) create mode 100644 benchmark/rust/Cargo.toml create mode 100644 benchmark/rust/README.md create mode 100644 benchmark/rust/benches/engine.rs create mode 100644 benchmark/rust/src/lib.rs rename benchmark/{ => ts}/CHANGELOG.md (100%) create mode 100644 benchmark/ts/README.md rename benchmark/{ => ts}/competitors.ts (100%) rename benchmark/{ => ts}/demo/CHANGELOG.md (100%) rename benchmark/{ => ts}/demo/README.md (99%) rename benchmark/{ => ts}/demo/index.html (100%) rename benchmark/{ => ts}/demo/package.json (100%) rename benchmark/{ => ts}/demo/src/app.tsx (100%) rename benchmark/{ => ts}/demo/src/engines.ts (100%) rename benchmark/{ => ts}/demo/src/feed.ts (100%) rename benchmark/{ => ts}/demo/src/main.tsx (100%) rename benchmark/{ => ts}/demo/src/metrics.ts (100%) rename benchmark/{ => ts}/demo/src/panels.tsx (100%) rename benchmark/{ => ts}/demo/vite.config.ts (91%) rename benchmark/{ => ts}/index.ts (93%) rename benchmark/{ => ts}/package.json (100%) rename benchmark/{ => ts}/report.ts (100%) rename benchmark/{ => ts}/tests/compose.ts (100%) rename benchmark/{ => ts}/tests/computed.ts (100%) rename benchmark/{ => ts}/tests/construct.ts (100%) rename benchmark/{ => ts}/tests/engine.ts (100%) rename benchmark/{ => ts}/tests/fan-out.ts (100%) rename benchmark/{ => ts}/tests/memory.ts (100%) rename benchmark/{ => ts}/tests/rendering/bench.tsx (100%) rename benchmark/{ => ts}/tests/rendering/run.ts (100%) rename benchmark/{ => ts}/tsconfig.json (66%) diff --git a/.claude/skills/benchmark/SKILL.md b/.claude/skills/benchmark/SKILL.md index 88461dd..d444595 100644 --- a/.claude/skills/benchmark/SKILL.md +++ b/.claude/skills/benchmark/SKILL.md @@ -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 @@ -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. @@ -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** @@ -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 @@ -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. diff --git a/.github/workflows/deploy-benchmark.yml b/.github/workflows/deploy-benchmark.yml index 88ad315..e8e6a98 100644 --- a/.github/workflows/deploy-benchmark.yml +++ b/.github/workflows/deploy-benchmark.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 85195bc..41a5033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ editing files in that scope — it overrides anything here for that scope | 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 | +| 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`) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2439d17..e949811 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,11 +31,12 @@ Target one package or one file instead of the whole workspace: 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` | +| 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 diff --git a/Cargo.lock b/Cargo.lock index 94f4996..076a3ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,13 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "dunky-benchmark" +version = "0.1.0" +dependencies = [ + "dunky-core", +] + [[package]] name = "dunky-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 06350bd..9d902e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ # `cargo test --workspace` for the others. [workspace] resolver = "3" -members = ["crates/core", "crates/macros", "crates/wasm", "crates/sandbox"] +members = ["crates/core", "crates/macros", "crates/wasm", "crates/sandbox", "benchmark/rust"] # Built by its own pipeline (uniffi-bindgen-react-native) for iOS/Android. exclude = ["crates/uniffi"] diff --git a/README.md b/README.md index 866a032..c9ee144 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ engine is built for it: | Memory / machine, 64-field (KB) | **4.4** | 4.1 | **134** | → **~7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the -**[benchmark README](./benchmark/README.md)**. +**[benchmark README](./benchmark/ts/README.md)**. **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. @@ -73,7 +73,7 @@ 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. -- **[`benchmark/README.md`](./benchmark/README.md)** — what's measured, the methodology, and results vs. XState & Zag. +- **[`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. diff --git a/TODO.md b/TODO.md index 36de361..be66589 100644 --- a/TODO.md +++ b/TODO.md @@ -31,5 +31,5 @@ ran on the Rust engine, is on the local branch `rust-engine-under-ts` (head `4e4 ## Later - [ ] Review what else to bring back from `rust-engine-under-ts` on the TS side. -- [ ] Benchmarks split by engine: the TS suite moves to `benchmark/ts`, and a Rust suite - lives in `benchmark/rust` (the Rust engine measured natively, on its own). +- [x] Benchmarks split by engine: `benchmark/ts` (the TS suite) and `benchmark/rust` (the + Rust engine, natively, on the same scenarios). diff --git a/benchmark/README.md b/benchmark/README.md index f0fe616..f7fc134 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,294 +1,11 @@ -# Benchmark suite +# Benchmarks -A performance harness for `@dunky.dev/state-machine`. It measures the engine's -hot paths in isolation and compares the runnable parts against -[XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). +Dunky has two engines that follow one spec, and each has its own suite: -Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures -vary by machine, Node version, and thermal state — **run it yourself** — but the -ranking and the scaling shape are what hold. +| Suite | What it measures | Run | +| ----------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------- | +| [`ts/`](./ts) | The TS engine (`@dunky.dev/state-machine`) against XState and Zag, plus React rendering and a live demo | `pnpm benchmark` | +| [`rust/`](./rust) | The Rust engine (`dunky-core`), natively, on the same scenarios | `cargo bench -p dunky-benchmark` | -> Want to _see_ it instead of read tables? **`pnpm benchmark:demo`** opens a live -> side-by-side where each engine drives a grid of real per-cell machines under a -> ramping load — watch which panels' backlogs grow as the load climbs. See -> [`demo/`](./demo/README.md). - -## Running - -```bash -pnpm benchmark # from the repo root (delegates here) -# or -cd benchmark && pnpm benchmark -``` - -Either runs the whole suite in one Node process with `--expose-gc` (the memory -bench needs accurate GC; the flag is harmless for the rest). Output is a series -of `console.table`s grouped by section. - -Each section is also exported as a `run*()` function, so you can run one in -isolation: - -```bash -node --expose-gc --import tsx -e "import('./tests/memory').then(m => m.runMemory())" -``` - -## Who's compared, and why not always all three - -The engines don't all fit every test, because they don't all run the same way: - -| Engine | How `send` works | Where it's measured | -| ---------- | ----------------------------- | -------------------------------------------------- | -| **Dunky** | synchronous | everywhere | -| **XState** | synchronous | the ops/sec loops, construction, memory, rendering | -| **Zag** | **async** (microtask-batched) | construction, memory, and React rendering only | - -A `tinybench` ops/sec loop counts synchronous iterations, so it can only compare -**synchronous** engines fairly. Zag's headless `send` is microtask-batched, so it -would either deadlock or report meaningless numbers in a tight sync loop — it -appears only where it runs synchronously (construction, memory via the headless -`VanillaMachine`) and in the React arena, where it runs natively via -`@zag-js/react`. - -Two XState variants appear in the fine-grain tables: - -- **`xstate`** — `actor.subscribe` with a hand-written `value` diff in the - listener (the same dedup Dunky does for free). -- **`xstate-raw`** — stock `actor.subscribe`, which fires on _every_ snapshot - change with no diff. - -Showing both separates "what XState costs out of the box" from "what it costs -once you add a differ." - -A key fairness rule across construction + memory: **all engines share one -module-level config across instances** — the shape a real app has (a component's -machine config is a `const`; every instance reuses it). So those loops time -_machine construction_, not config-literal allocation. - -> **How to read the ops/sec tables.** `ops/sec` (higher is better) is the headline; -> `±rme %` is the run-to-run noise floor — a gap between two rows is only real if -> it clears both rows' margins. The `(anti-DCE SINK: …)` lines in the raw output -> are just proof the JIT didn't dead-code-eliminate the work — ignore them. - -**Every comparison table lists all three engines**, so you always see the full -field. Where one can't run a given test, the cell is marked — same marker -everywhere: - -- **`n/a ᵃ`** — _async._ Zag's `send` is microtask-batched, so it can't run in a - synchronous ops/sec or `flushSync` loop. -- **`n/a ᶠ`** — _no equivalent feature._ The engine has no first-class primitive - for this scenario (e.g. XState has no lazy/memoized `computed`), so there's - nothing comparable to time. - ---- - -## 1. Fan-out / fine-grain / throughput (`tests/fan-out.ts`) - -The selection layer at scale — the thing that decides whether thousands of -machines stay cheap. Zag can't run these (async `send`). - -**A. Propagation — change 1 of N.** ONE machine, N fields, N observers (one per -field). Bump one field. Dunky's `select` is a coarse bus: every selection -re-evaluates its selector on each notify and value-compares, so only the touched -field's _listener_ fires (downstream is O(changed)) — but the re-eval pass itself -is O(N observers) per write. The table shows _how_ that degrades with N versus -XState's coarse `actor.subscribe`. - -| Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | -| ------------- | --------------: | ---------------: | ----: | -| 100 | 658.5 K | 437.8 K | n/a ᵃ | -| 1000 | 56.8 K | 49.2 K | n/a ᵃ | -| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | - -→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's -coarse subscribe degrades much faster as the observer set grows. - -**B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The -dedup layer re-evaluates and value-compares, so no listener fires — the -subscriber-side cost is ~zero. This is the "irrelevant write" that a cell-per-field -model gets for free and a coarse bus has to work to ignore. - -| Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | -| ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | -| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | - -→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 -cells); the value-deduping bus skips waking observers entirely. - -**C. Throughput — single machine, one event.** Per-transition cost with no -selection scaling — the raw `send` price. - -| Single machine, one event | ops/sec | -| ------------------------- | ---------: | -| Dunky | **11.6 M** | -| XState (raw) | 1.6 M | -| XState (diffed) | 1.6 M | -| Zag | n/a ᵃ | - -→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, -so a transition allocates nothing; XState builds a fresh snapshot per event. - -## 2. Compose / synced machines (`tests/compose.ts`) - -The cross-region machinery `compose` adds, scaled by member count. No competitor -column: neither XState nor Zag has a first-class orthogonal-region primitive that -maps to `combine`/`sync`, so there's nothing equivalent to time (**n/a ᶠ** for -both). - -**A. combine** — one value-deduped `Selection` derived across M members but reading -only `m0`. It re-evaluates on _any_ member change but fires its listener only when -`m0` changes. **B. sync** — a coarse cross-region rule that wakes on _any_ member -change (the O(members) path by design). - -| Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | -| ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | -| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | -| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | - -→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. - -> A third "chain" sub-test (a sync rule that `send()`s downstream every change) was -> removed: under a tight loop it shows superlinear slowdown. That's a real -> `compose.sync` + cross-machine-send interaction worth investigating on its own, -> not a benchmark-tuning artifact — see the note in `tests/compose.ts`. - -## 3. Computed (`tests/computed.ts`) - -The most machinery-heavy subsystem — read-key tracking via proxies, memoization -against a dep snapshot, glitch-free computed→computed chains. This is a subsystem -profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Zag's -`send` is async (**n/a ᵃ**), so neither has a comparable primitive to time. - -| Scenario | Dunky (ops/sec) | XState | Zag | -| ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | - -→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means -you only pay the recompute when an input you actually read changes. - -## 4. Engine hot paths (`tests/engine.ts`) - -The parts of `send` that do real statechart work (everything else in the suite -stays in one state and only mutates context). These probe Dunky internals in -isolation — there's no comparable isolated path to time in XState (**n/a ᶠ**), and -Zag's `send` is async (**n/a ᵃ**). - -| Scenario | Dunky (ops/sec) | XState | Zag | -| -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full -state transitions with entry/exit actions, and effect boot/cleanup every -transition all stay in the same order of magnitude as a bare `send`. - -## 5. Construction cost (`tests/construct.ts`) - -Wall-clock to build + `start()` N machines, no events sent (matches a real mount). -Synchronous for all three, so it's a fair three-way table. Median of 5 passes, JIT -warmed first. - -| Build + start | Dunky (µs/machine) | XState | Zag | -| ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.58 | 1.35 | 5.06 | - -→ Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.2× faster than Zag's per-field reactive cells. - -## 6. Memory per machine (`tests/memory.ts`) - -Build 5000 machines, hold them live, report retained heap per machine (`heapMB()` -double-GCs before sampling). Two context widths — **thin** (2 fields) and **fat** -(64 fields) — because the whole point of the plain-object model is that memory -stays ~flat in field count. Rows below are the **written** mode (one `hit` each — -the footprint a churny app actually pays). - -| Context | Dunky (KB/machine) | XState | Zag | -| -------- | -----------------: | -----: | ------: | -| 2-field | 3.85 | 3.61 | 8.92 | -| 64-field | 4.35 | 4.10 | **134** | - -→ Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with -the data you store, not with a per-field cell. **Zag is the contrast**: one reactive -cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than -Dunky. - -**Idle vs written.** Dunky owns its context copy from construction and mutates -it in place forever, so its idle and written footprints match by design — while a -lazy-copy scheme steps up once writes start: - -| 64-field, 5000 machines | Dunky | XState | Zag | -| ----------------------- | ----: | -----: | --: | -| Idle (never written) | 4.35 | 3.55 | 130 | -| Written (1 event each) | 4.35 | 4.10 | 134 | - -→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, -so its written row grows. - -## 7. React rendering (`tests/rendering/`) - -The thing that actually hurts in an app — how many React components render — under -jsdom. A list of N rows, 50 highlight moves. Two numbers: **rows woken / move** -(the fine-grained payoff) and **wall-clock** per phase. Strategies: `selector` -(shared machine + `useSelector` per row), `naive` (whole-snapshot — the -anti-pattern), `core/instance` (one machine per row), plus `xstate/selector` and -`zag/instance`. - -List of 1000 rows: - -| Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | -| -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 3.6 | **2.4** | -| Dunky/selector | 2 | 4.9 | 3.8 | -| xstate/selector | 2 | 3.7 | 4.3 | -| zag/instance | 2 | 3.9 | n/a ᵃ | -| naive (anti-pattern) | **980** | 4.5 | 38.3 | - -→ Every properly-set-up engine wakes only the **2** rows that changed (vs. the -naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× -faster than XState**. - -Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the -microtask-batched `send` can't be timed under a synchronous `flushSync` loop, so -only its row-count is comparable. - -## Where this actually matters - -The loads here (and in the demo) are deliberately extreme to force the engines to -diverge. That only reflects real software when a single view holds **thousands of -independently-stateful, live-updating cells in one frame budget**. That's not a -hypothetical — these are whole product categories: - -| Real workload | Example products | Where the count comes from | Live cells | -| ----------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | ---------: | -| **Full L2 order book / DOM ladder** | Bookmap, Sierra Chart, ATAS | 500–2,000 price levels per book × several books, each level a live cell | 3k–15k | -| **Options chain / vol surface** | thinkorswim, Tastytrade, OptionStrat | hundreds of strikes × calls+puts × 8–16 live fields (bid/ask/IV/Δ/Γ/Θ/V) | 5k–20k | -| **Screeners / heatmaps** | TradingView screener, CoinMarketCap, Finviz | 1,000–5,000 symbols × a few live fields (price, %chg, flash, sparkline) | 3k–15k | -| **Live-data spreadsheets** | Excel/Sheets + market add-ins, Bloomberg BQNT | a visible sheet is 5k–20k cells, many bound to streaming feeds + recompute | 5k–20k | -| **Observability walls** | Grafana, Datadog, Netdata | hundreds of panels × series, or a per-second host grid | 5k–50k | -| **Live log / trace tails** | Datadog Live Tail, Kibana, Sentry | streaming rows, each a tiny stateful unit, into a long virtualized buffer | 5k–50k | -| **Dense collaborative canvases** | Figma, tldraw | 5k–50k nodes; a `pointermove` / cursor stream fans out to all visible shapes | 5k–50k | -| **NOC / k8s / network walls** | k9s, Lens, traffic grids | thousands of pods/nodes/flows, each a live cell | 5k–20k | - -The sharpest target is a **dense live financial surface** (full order book or -options chain): genuinely 5k–20k live cells, updating tens of thousands of times a -second, often needed on **web _and_ native** from one codebase, where flat -per-cell memory matters — every property the benchmark measures pays at once. - -> **What does _not_ need this:** a handful-of-symbols chart view, a typical SaaS -> dashboard, a chat app — dozens to a few hundred live elements. The engine works -> there too, but so does anything; at that scale you'd pick it for the -> agnostic-render and bundle/memory reasons, not the throughput these tables show. +The Rust rows are twins of TS rows, so a change to one engine can be measured against the +same work on the other. diff --git a/benchmark/rust/Cargo.toml b/benchmark/rust/Cargo.toml new file mode 100644 index 0000000..98e41e0 --- /dev/null +++ b/benchmark/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-benchmark" +description = "The Rust engine's benchmark: the TS suite's engine scenarios, run natively on dunky-core." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +dunky-core = { workspace = true } + +[[bench]] +name = "engine" +harness = false diff --git a/benchmark/rust/README.md b/benchmark/rust/README.md new file mode 100644 index 0000000..6e4c74d --- /dev/null +++ b/benchmark/rust/README.md @@ -0,0 +1,66 @@ +# Rust benchmark suite + +The Rust engine (`dunky-core`) measured natively, on its own. Each row is the twin of a +row in the TS suite ([`../ts`](../ts/README.md)): the same machine shape and the same +work, so the two engines can be tuned against the same scenarios. + +Numbers below are the average of two runs (release build, Apple M5 Pro). Absolute +figures vary by machine and thermal state — **run it yourself**. + +## Running + +```bash +cargo bench -p dunky-benchmark +``` + +A plain `cargo bench` target with no extra dependency: ops/sec is the median of 5 +samples of ~100 ms after a 100 ms warmup; memory counts live heap bytes with a counting +allocator. The machines are in [`src/lib.rs`](./src/lib.rs), the runner in +[`benches/engine.rs`](./benches/engine.rs). + +## Results + +The **TS engine** column is the same row in [`../ts`](../ts/README.md), for reference +only: native Rust and JS on V8 are different runtimes, so this is not a contest. + +### Throughput and fan-out + +| Scenario | Rust (ops/sec) | TS engine | +| ----------------------------- | -------------: | --------: | +| One event (observed field) | **52.3 M** | 11.6 M | +| Unobserved write, 1,000 cells | **79.6 M** | 5.4 M | +| Unobserved write, 5,000 cells | **76.9 M** | 4.4 M | +| 1 change, 64 observers | **4.0 M** | — | + +A Rust context is a typed struct, with at most 64 fields, so the TS suite's 5,000-field +fan-out has no Rust twin. Its Rust row uses the 64-field maximum: one value-deduped +selection per field. + +### Computed + +| Scenario | Rust (ops/sec) | TS engine | +| ------------------------------------ | -------------: | --------: | +| Cached read (no change) | **355.7 M** | 44.0 M | +| Fine-grain (change unread, re-read) | **107.5 M** | 10.7 M | +| Recompute (change read field) | **42.0 M** | 4.9 M | +| 4-deep chain (change root, read tip) | **11.8 M** | 1.5 M | + +### Engine paths + +| Scenario | Rust (ops/sec) | TS engine | +| --------------------------------------------- | -------------: | --------: | +| Guard fallthrough, 2 candidates | **157.6 M** | 5.2 M | +| Guard fallthrough, 8 candidates | **62.4 M** | 4.5 M | +| Guard fallthrough, 32 candidates | **16.7 M** | 3.2 M | +| State churn (exit + entry every event) | **87.4 M** | 8.8 M | +| Effect churn (boot + cleanup each transition) | **60.3 M** | 8.2 M | + +### Construction and memory + +| Scenario | Rust | TS engine | +| ----------------------------------------- | ----------: | --------: | +| Build + start, per machine (10,000) | **23 ns** | 1.58 µs | +| Memory, 2-field context, written (5,000) | **0.35 KB** | 3.85 KB | +| Memory, 64-field context, written (5,000) | **0.59 KB** | 4.35 KB | + +Rendering and composition are TS-only (React, `compose`), so they have no Rust rows. diff --git a/benchmark/rust/benches/engine.rs b/benchmark/rust/benches/engine.rs new file mode 100644 index 0000000..520441c --- /dev/null +++ b/benchmark/rust/benches/engine.rs @@ -0,0 +1,210 @@ +//! The Rust engine's benchmark: the TS suite's engine scenarios, run natively. +//! +//! cargo bench -p dunky-benchmark +//! +//! Each row is the twin of a row in `benchmark/ts`, so the two READMEs compare the same +//! work. ops/sec is the median of 5 samples of ~100 ms, after a 100 ms warmup. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use dunky_benchmark::*; +use dunky_core::Machine; + +/// Counts live heap bytes, for the memory rows. +struct Counting; + +static LIVE: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + LIVE.fetch_add(layout.size(), Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + LIVE.fetch_sub(layout.size(), Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +const SAMPLES: usize = 5; +const SAMPLE: Duration = Duration::from_millis(100); +const BATCH: u64 = 1_000; + +fn ops_per_sec(mut op: impl FnMut()) -> f64 { + let warm = Instant::now(); + while warm.elapsed() < SAMPLE { + for _ in 0..BATCH { + op(); + } + } + let mut samples = Vec::with_capacity(SAMPLES); + for _ in 0..SAMPLES { + let start = Instant::now(); + let mut n = 0u64; + while start.elapsed() < SAMPLE { + for _ in 0..BATCH { + op(); + } + n += BATCH; + } + samples.push(n as f64 / start.elapsed().as_secs_f64()); + } + samples.sort_by(f64::total_cmp); + samples[SAMPLES / 2] +} + +fn human(ops: f64) -> String { + if ops >= 1e6 { + format!("{:.1} M", ops / 1e6) + } else if ops >= 1e3 { + format!("{:.1} K", ops / 1e3) + } else { + format!("{ops:.0}") + } +} + +fn section(title: &str) { + println!("\n### {title}"); + println!( + "| {:<46} | {:>10} | {:>8} |", + "scenario", "ops/sec", "ns/op" + ); + println!("| {:-<46} | {:->10} | {:->8} |", "", "", ""); +} + +fn row(name: &str, op: impl FnMut()) { + let ops = ops_per_sec(op); + println!("| {name:<46} | {:>10} | {:>8.1} |", human(ops), 1e9 / ops); +} + +fn main() { + println!("Rust engine benchmark (dunky-core, native, release build)"); + + section("Throughput and fan-out"); + let cell = cell_config(); + let m = observed_cell(&cell); + row("one event (observed field)", || m.send(CellEvent::Hit)); + for n in [1_000, 5_000] { + let cells: Vec<_> = (0..n).map(|_| observed_cell(&cell)).collect(); + let mut i = 0; + row(&format!("unobserved write, {n} cells"), || { + cells[i % n].send(CellEvent::Miss); + i += 1; + }); + } + let wide = Machine::new(&wide_config()); + wide.start(); + for k in 0..64 { + let sel = wide.select(move |v| wide_field(v.context(), k)); + std::mem::forget(sel.subscribe(|_| bump())); + } + let mut i = 0; + row("1 change, 64 observers", || { + wide.send(WideEvent::Set { key: i % 64 }); + i += 1; + }); + + section("Computed"); + let (config, keys) = computed_config(); + let m = Machine::new(&config); + m.start(); + m.send(ComputedEvent::BumpA); + black_box(m.computed(keys.sum)); + row("cached read (no change)", || { + black_box(m.computed(keys.sum)); + }); + row("fine-grain (change unread, re-read)", || { + m.send(ComputedEvent::BumpUnrelated); + black_box(m.computed(keys.sum)); + }); + row("recompute (change read field)", || { + m.send(ComputedEvent::BumpA); + black_box(m.computed(keys.sum)); + }); + row("4-deep chain (change root, read tip)", || { + m.send(ComputedEvent::BumpA); + black_box(m.computed(keys.c4)); + }); + + section("Engine paths"); + for k in [2, 8, 32] { + let m = Machine::new(&guards_config(k)); + m.start(); + row(&format!("guard fallthrough, {k} candidates"), || { + m.send(Go::Go) + }); + } + let m = Machine::new(&state_churn_config()); + m.start(); + row("state churn (exit + entry every event)", || m.send(Go::Go)); + let m = Machine::new(&effect_churn_config()); + m.start(); + row("effect churn (boot + cleanup each transition)", || { + m.send(Go::Go) + }); + + println!("\n### Construction and memory"); + println!("| {:<46} | {:>10} |", "scenario", "value"); + println!("| {:-<46} | {:->10} |", "", ""); + let mut passes: Vec = (0..5) + .map(|_| { + let start = Instant::now(); + let machines: Vec<_> = (0..10_000) + .map(|_| { + let m = Machine::new(&cell); + m.start(); + m + }) + .collect(); + let ns = start.elapsed().as_secs_f64() * 1e9 / 10_000.0; + black_box(machines); + ns + }) + .collect(); + passes.sort_by(f64::total_cmp); + println!( + "| {:<46} | {:>7.0} ns |", + "build + start, per machine (10,000)", passes[2] + ); + + let n = 5_000; + let before = LIVE.load(Ordering::Relaxed); + let thin: Vec<_> = (0..n) + .map(|_| { + let m = Machine::new(&cell); + m.start(); + m.send(CellEvent::Hit); + m + }) + .collect(); + let per = (LIVE.load(Ordering::Relaxed) - before) as f64 / n as f64 / 1024.0; + println!( + "| {:<46} | {:>7.2} KB |", + "memory, 2-field context, written (5,000)", per + ); + drop(thin); + let wide = wide_config(); + let before = LIVE.load(Ordering::Relaxed); + let fat: Vec<_> = (0..n) + .map(|_| { + let m = Machine::new(&wide); + m.start(); + m.send(WideEvent::Set { key: 0 }); + m + }) + .collect(); + let per = (LIVE.load(Ordering::Relaxed) - before) as f64 / n as f64 / 1024.0; + println!( + "| {:<46} | {:>7.2} KB |", + "memory, 64-field context, written (5,000)", per + ); + drop(fat); + + println!("\n(sink: {})", sink()); +} diff --git a/benchmark/rust/src/lib.rs b/benchmark/rust/src/lib.rs new file mode 100644 index 0000000..a9588d5 --- /dev/null +++ b/benchmark/rust/src/lib.rs @@ -0,0 +1,512 @@ +//! The machines of the Rust benchmark: twins of the TS suite's machines +//! (`benchmark/ts/competitors.ts` and `benchmark/ts/tests`), so a row measures the same +//! work on each engine. `benches/engine.rs` runs them. + +use std::cell::Cell; + +use dunky_core::{ComputedKey, Config, Context, Event, Machine, State, Types}; + +thread_local! { + static SINK: Cell = const { Cell::new(0) }; +} + +/// Side work an action or a listener does, so nothing is optimized away. +pub fn bump() { + SINK.with(|s| s.set(s.get() + 1)); +} + +pub fn sink() -> u64 { + SINK.with(Cell::get) +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum Idle { + Idle, +} + +// --------------------------------------------------------------------------------- +// The cell: `hit` bumps `value`, `miss` bumps `other` (makeCoreCell). + +#[derive(Event, Clone, Debug)] +pub enum CellEvent { + Hit, + Miss, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct CellCtx { + pub value: u32, + pub other: u32, +} + +pub struct CellMachine; +impl Types for CellMachine { + type State = Idle; + type Event = CellEvent; + type Context = CellCtx; +} + +pub fn cell_config() -> Config { + let mut b = Config::::builder(Idle::Idle, CellCtx { value: 0, other: 0 }); + b.state(Idle::Idle, |s| { + s.on(CellEventKind::Hit, |t| { + t.act(|p| CellCtx::patch().value(p.context().value + 1)) + }) + .on(CellEventKind::Miss, |t| { + t.act(|p| CellCtx::patch().other(p.context().other + 1)) + }) + }); + b.build() +} + +/// A started cell whose `value` is observed (a value-deduped selection), like the TS cell. +pub fn observed_cell(config: &Config) -> Machine { + let m = Machine::new(config); + m.start(); + std::mem::forget(m.select(|v| v.context().value).subscribe(|_| bump())); + m +} + +// --------------------------------------------------------------------------------- +// The widest context: 64 fields, one observer per field (makeCoreFanout, at the most a +// typed Rust context holds). + +#[derive(Event, Clone, Debug)] +pub enum WideEvent { + Set { key: usize }, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct W64 { + pub f0: u32, + pub f1: u32, + pub f2: u32, + pub f3: u32, + pub f4: u32, + pub f5: u32, + pub f6: u32, + pub f7: u32, + pub f8: u32, + pub f9: u32, + pub f10: u32, + pub f11: u32, + pub f12: u32, + pub f13: u32, + pub f14: u32, + pub f15: u32, + pub f16: u32, + pub f17: u32, + pub f18: u32, + pub f19: u32, + pub f20: u32, + pub f21: u32, + pub f22: u32, + pub f23: u32, + pub f24: u32, + pub f25: u32, + pub f26: u32, + pub f27: u32, + pub f28: u32, + pub f29: u32, + pub f30: u32, + pub f31: u32, + pub f32: u32, + pub f33: u32, + pub f34: u32, + pub f35: u32, + pub f36: u32, + pub f37: u32, + pub f38: u32, + pub f39: u32, + pub f40: u32, + pub f41: u32, + pub f42: u32, + pub f43: u32, + pub f44: u32, + pub f45: u32, + pub f46: u32, + pub f47: u32, + pub f48: u32, + pub f49: u32, + pub f50: u32, + pub f51: u32, + pub f52: u32, + pub f53: u32, + pub f54: u32, + pub f55: u32, + pub f56: u32, + pub f57: u32, + pub f58: u32, + pub f59: u32, + pub f60: u32, + pub f61: u32, + pub f62: u32, + pub f63: u32, +} + +pub struct Wide; +impl Types for Wide { + type State = Idle; + type Event = WideEvent; + type Context = W64; +} + +/// Field `k` of the wide context (a typed context has no field by index). +pub fn wide_field(c: &W64, k: usize) -> u32 { + match k % 64 { + 0 => c.f0, + 1 => c.f1, + 2 => c.f2, + 3 => c.f3, + 4 => c.f4, + 5 => c.f5, + 6 => c.f6, + 7 => c.f7, + 8 => c.f8, + 9 => c.f9, + 10 => c.f10, + 11 => c.f11, + 12 => c.f12, + 13 => c.f13, + 14 => c.f14, + 15 => c.f15, + 16 => c.f16, + 17 => c.f17, + 18 => c.f18, + 19 => c.f19, + 20 => c.f20, + 21 => c.f21, + 22 => c.f22, + 23 => c.f23, + 24 => c.f24, + 25 => c.f25, + 26 => c.f26, + 27 => c.f27, + 28 => c.f28, + 29 => c.f29, + 30 => c.f30, + 31 => c.f31, + 32 => c.f32, + 33 => c.f33, + 34 => c.f34, + 35 => c.f35, + 36 => c.f36, + 37 => c.f37, + 38 => c.f38, + 39 => c.f39, + 40 => c.f40, + 41 => c.f41, + 42 => c.f42, + 43 => c.f43, + 44 => c.f44, + 45 => c.f45, + 46 => c.f46, + 47 => c.f47, + 48 => c.f48, + 49 => c.f49, + 50 => c.f50, + 51 => c.f51, + 52 => c.f52, + 53 => c.f53, + 54 => c.f54, + 55 => c.f55, + 56 => c.f56, + 57 => c.f57, + 58 => c.f58, + 59 => c.f59, + 60 => c.f60, + 61 => c.f61, + 62 => c.f62, + 63 => c.f63, + _ => unreachable!(), + } +} + +pub fn wide_config() -> Config { + let mut b = Config::::builder( + Idle::Idle, + W64 { + f0: 0, + f1: 0, + f2: 0, + f3: 0, + f4: 0, + f5: 0, + f6: 0, + f7: 0, + f8: 0, + f9: 0, + f10: 0, + f11: 0, + f12: 0, + f13: 0, + f14: 0, + f15: 0, + f16: 0, + f17: 0, + f18: 0, + f19: 0, + f20: 0, + f21: 0, + f22: 0, + f23: 0, + f24: 0, + f25: 0, + f26: 0, + f27: 0, + f28: 0, + f29: 0, + f30: 0, + f31: 0, + f32: 0, + f33: 0, + f34: 0, + f35: 0, + f36: 0, + f37: 0, + f38: 0, + f39: 0, + f40: 0, + f41: 0, + f42: 0, + f43: 0, + f44: 0, + f45: 0, + f46: 0, + f47: 0, + f48: 0, + f49: 0, + f50: 0, + f51: 0, + f52: 0, + f53: 0, + f54: 0, + f55: 0, + f56: 0, + f57: 0, + f58: 0, + f59: 0, + f60: 0, + f61: 0, + f62: 0, + f63: 0, + }, + ); + b.state(Idle::Idle, |s| { + s.on(WideEventKind::Set, |t| { + t.act(|p| { + let Some(WideEvent::Set { key }) = p.event() else { + return W64::patch(); + }; + let c = p.context(); + match key % 64 { + 0 => W64::patch().f0(c.f0 + 1), + 1 => W64::patch().f1(c.f1 + 1), + 2 => W64::patch().f2(c.f2 + 1), + 3 => W64::patch().f3(c.f3 + 1), + 4 => W64::patch().f4(c.f4 + 1), + 5 => W64::patch().f5(c.f5 + 1), + 6 => W64::patch().f6(c.f6 + 1), + 7 => W64::patch().f7(c.f7 + 1), + 8 => W64::patch().f8(c.f8 + 1), + 9 => W64::patch().f9(c.f9 + 1), + 10 => W64::patch().f10(c.f10 + 1), + 11 => W64::patch().f11(c.f11 + 1), + 12 => W64::patch().f12(c.f12 + 1), + 13 => W64::patch().f13(c.f13 + 1), + 14 => W64::patch().f14(c.f14 + 1), + 15 => W64::patch().f15(c.f15 + 1), + 16 => W64::patch().f16(c.f16 + 1), + 17 => W64::patch().f17(c.f17 + 1), + 18 => W64::patch().f18(c.f18 + 1), + 19 => W64::patch().f19(c.f19 + 1), + 20 => W64::patch().f20(c.f20 + 1), + 21 => W64::patch().f21(c.f21 + 1), + 22 => W64::patch().f22(c.f22 + 1), + 23 => W64::patch().f23(c.f23 + 1), + 24 => W64::patch().f24(c.f24 + 1), + 25 => W64::patch().f25(c.f25 + 1), + 26 => W64::patch().f26(c.f26 + 1), + 27 => W64::patch().f27(c.f27 + 1), + 28 => W64::patch().f28(c.f28 + 1), + 29 => W64::patch().f29(c.f29 + 1), + 30 => W64::patch().f30(c.f30 + 1), + 31 => W64::patch().f31(c.f31 + 1), + 32 => W64::patch().f32(c.f32 + 1), + 33 => W64::patch().f33(c.f33 + 1), + 34 => W64::patch().f34(c.f34 + 1), + 35 => W64::patch().f35(c.f35 + 1), + 36 => W64::patch().f36(c.f36 + 1), + 37 => W64::patch().f37(c.f37 + 1), + 38 => W64::patch().f38(c.f38 + 1), + 39 => W64::patch().f39(c.f39 + 1), + 40 => W64::patch().f40(c.f40 + 1), + 41 => W64::patch().f41(c.f41 + 1), + 42 => W64::patch().f42(c.f42 + 1), + 43 => W64::patch().f43(c.f43 + 1), + 44 => W64::patch().f44(c.f44 + 1), + 45 => W64::patch().f45(c.f45 + 1), + 46 => W64::patch().f46(c.f46 + 1), + 47 => W64::patch().f47(c.f47 + 1), + 48 => W64::patch().f48(c.f48 + 1), + 49 => W64::patch().f49(c.f49 + 1), + 50 => W64::patch().f50(c.f50 + 1), + 51 => W64::patch().f51(c.f51 + 1), + 52 => W64::patch().f52(c.f52 + 1), + 53 => W64::patch().f53(c.f53 + 1), + 54 => W64::patch().f54(c.f54 + 1), + 55 => W64::patch().f55(c.f55 + 1), + 56 => W64::patch().f56(c.f56 + 1), + 57 => W64::patch().f57(c.f57 + 1), + 58 => W64::patch().f58(c.f58 + 1), + 59 => W64::patch().f59(c.f59 + 1), + 60 => W64::patch().f60(c.f60 + 1), + 61 => W64::patch().f61(c.f61 + 1), + 62 => W64::patch().f62(c.f62 + 1), + 63 => W64::patch().f63(c.f63 + 1), + _ => unreachable!(), + } + }) + }) + }); + b.build() +} + +// --------------------------------------------------------------------------------- +// Computed: `sum` reads a + b; a 4-deep chain off `a` (benchmark/ts/tests/computed.ts). + +#[derive(Event, Clone, Debug)] +pub enum ComputedEvent { + BumpA, + BumpUnrelated, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct ComputedCtx { + pub a: u32, + pub b: u32, + pub unrelated: u32, +} + +pub struct Computed; +impl Types for Computed { + type State = Idle; + type Event = ComputedEvent; + type Context = ComputedCtx; +} + +pub struct ComputedKeys { + pub sum: ComputedKey, + pub c4: ComputedKey, +} + +pub fn computed_config() -> (Config, ComputedKeys) { + let mut b = Config::::builder( + Idle::Idle, + ComputedCtx { + a: 0, + b: 0, + unrelated: 0, + }, + ); + let sum = b.computed("sum", |c| c.context.a() + c.context.b()); + let c1 = b.computed("c1", |c| c.context.a() + 1); + let c2 = b.computed("c2", move |c| *c.computed(c1) + 1); + let c3 = b.computed("c3", move |c| *c.computed(c2) + 1); + let c4 = b.computed("c4", move |c| *c.computed(c3) + 1); + b.state(Idle::Idle, |s| { + s.on(ComputedEventKind::BumpA, |t| { + t.act(|p| ComputedCtx::patch().a(p.context().a + 1)) + }) + .on(ComputedEventKind::BumpUnrelated, |t| { + t.act(|p| ComputedCtx::patch().unrelated(p.context().unrelated + 1)) + }) + }); + (b.build(), ComputedKeys { sum, c4 }) +} + +// --------------------------------------------------------------------------------- +// Engine paths (benchmark/ts/tests/engine.ts). + +#[derive(Event, Clone, Debug)] +pub enum Go { + Go, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct PickCtx { + pub pick: u32, +} + +pub struct Guards; +impl Types for Guards { + type State = Idle; + type Event = Go; + type Context = PickCtx; +} + +/// `k` guarded candidates for `go`; the last one passes, so every send walks them all. +pub fn guards_config(k: u32) -> Config { + let mut b = Config::::builder(Idle::Idle, PickCtx { pick: k - 1 }); + b.state(Idle::Idle, |mut s| { + for i in 0..k { + s = s.on(GoKind::Go, move |t| { + t.guard_fn(move |g| g.context().pick == i) + }); + } + s + }); + b.build() +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum PingPong { + Ping, + Pong, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct NCtx { + pub n: u32, +} + +pub struct Churn; +impl Types for Churn { + type State = PingPong; + type Event = Go; + type Context = NCtx; +} + +/// Ping <-> pong; each state has entry and exit actions. +pub fn state_churn_config() -> Config { + let mut b = Config::::builder(PingPong::Ping, NCtx { n: 0 }); + for (from, to) in [ + (PingPong::Ping, PingPong::Pong), + (PingPong::Pong, PingPong::Ping), + ] { + b.state(from, |s| { + s.entry_run(|_| bump()) + .exit_run(|_| bump()) + .on(GoKind::Go, move |t| t.target(to)) + }); + } + b.build() +} + +/// Ping <-> pong; each state boots an effect on entry and runs its cleanup on exit. +pub fn effect_churn_config() -> Config { + let mut b = Config::::builder(PingPong::Ping, NCtx { n: 0 }); + for (from, to) in [ + (PingPong::Ping, PingPong::Pong), + (PingPong::Pong, PingPong::Ping), + ] { + b.state(from, |s| { + s.effect_run(|_| { + bump(); + Some(Box::new(bump)) + }) + .on(GoKind::Go, move |t| t.target(to)) + }); + } + b.build() +} diff --git a/benchmark/CHANGELOG.md b/benchmark/ts/CHANGELOG.md similarity index 100% rename from benchmark/CHANGELOG.md rename to benchmark/ts/CHANGELOG.md diff --git a/benchmark/ts/README.md b/benchmark/ts/README.md new file mode 100644 index 0000000..7af911d --- /dev/null +++ b/benchmark/ts/README.md @@ -0,0 +1,294 @@ +# Benchmark suite + +A performance harness for `@dunky.dev/state-machine`. It measures the engine's +hot paths in isolation and compares the runnable parts against +[XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). + +Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures +vary by machine, Node version, and thermal state — **run it yourself** — but the +ranking and the scaling shape are what hold. + +> Want to _see_ it instead of read tables? **`pnpm benchmark:demo`** opens a live +> side-by-side where each engine drives a grid of real per-cell machines under a +> ramping load — watch which panels' backlogs grow as the load climbs. See +> [`demo/`](./demo/README.md). + +## Running + +```bash +pnpm benchmark # from the repo root (delegates here) +# or +cd benchmark/ts && pnpm benchmark +``` + +Either runs the whole suite in one Node process with `--expose-gc` (the memory +bench needs accurate GC; the flag is harmless for the rest). Output is a series +of `console.table`s grouped by section. + +Each section is also exported as a `run*()` function, so you can run one in +isolation: + +```bash +node --expose-gc --import tsx -e "import('./tests/memory').then(m => m.runMemory())" +``` + +## Who's compared, and why not always all three + +The engines don't all fit every test, because they don't all run the same way: + +| Engine | How `send` works | Where it's measured | +| ---------- | ----------------------------- | -------------------------------------------------- | +| **Dunky** | synchronous | everywhere | +| **XState** | synchronous | the ops/sec loops, construction, memory, rendering | +| **Zag** | **async** (microtask-batched) | construction, memory, and React rendering only | + +A `tinybench` ops/sec loop counts synchronous iterations, so it can only compare +**synchronous** engines fairly. Zag's headless `send` is microtask-batched, so it +would either deadlock or report meaningless numbers in a tight sync loop — it +appears only where it runs synchronously (construction, memory via the headless +`VanillaMachine`) and in the React arena, where it runs natively via +`@zag-js/react`. + +Two XState variants appear in the fine-grain tables: + +- **`xstate`** — `actor.subscribe` with a hand-written `value` diff in the + listener (the same dedup Dunky does for free). +- **`xstate-raw`** — stock `actor.subscribe`, which fires on _every_ snapshot + change with no diff. + +Showing both separates "what XState costs out of the box" from "what it costs +once you add a differ." + +A key fairness rule across construction + memory: **all engines share one +module-level config across instances** — the shape a real app has (a component's +machine config is a `const`; every instance reuses it). So those loops time +_machine construction_, not config-literal allocation. + +> **How to read the ops/sec tables.** `ops/sec` (higher is better) is the headline; +> `±rme %` is the run-to-run noise floor — a gap between two rows is only real if +> it clears both rows' margins. The `(anti-DCE SINK: …)` lines in the raw output +> are just proof the JIT didn't dead-code-eliminate the work — ignore them. + +**Every comparison table lists all three engines**, so you always see the full +field. Where one can't run a given test, the cell is marked — same marker +everywhere: + +- **`n/a ᵃ`** — _async._ Zag's `send` is microtask-batched, so it can't run in a + synchronous ops/sec or `flushSync` loop. +- **`n/a ᶠ`** — _no equivalent feature._ The engine has no first-class primitive + for this scenario (e.g. XState has no lazy/memoized `computed`), so there's + nothing comparable to time. + +--- + +## 1. Fan-out / fine-grain / throughput (`tests/fan-out.ts`) + +The selection layer at scale — the thing that decides whether thousands of +machines stay cheap. Zag can't run these (async `send`). + +**A. Propagation — change 1 of N.** ONE machine, N fields, N observers (one per +field). Bump one field. Dunky's `select` is a coarse bus: every selection +re-evaluates its selector on each notify and value-compares, so only the touched +field's _listener_ fires (downstream is O(changed)) — but the re-eval pass itself +is O(N observers) per write. The table shows _how_ that degrades with N versus +XState's coarse `actor.subscribe`. + +| Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | +| ------------- | --------------: | ---------------: | ----: | +| 100 | 658.5 K | 437.8 K | n/a ᵃ | +| 1000 | 56.8 K | 49.2 K | n/a ᵃ | +| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | + +→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's +coarse subscribe degrades much faster as the observer set grows. + +**B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The +dedup layer re-evaluates and value-compares, so no listener fires — the +subscriber-side cost is ~zero. This is the "irrelevant write" that a cell-per-field +model gets for free and a coarse bus has to work to ignore. + +| Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | +| ------------------------- | --------------: | ---------------: | ----: | +| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | +| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | + +→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 +cells); the value-deduping bus skips waking observers entirely. + +**C. Throughput — single machine, one event.** Per-transition cost with no +selection scaling — the raw `send` price. + +| Single machine, one event | ops/sec | +| ------------------------- | ---------: | +| Dunky | **11.6 M** | +| XState (raw) | 1.6 M | +| XState (diffed) | 1.6 M | +| Zag | n/a ᵃ | + +→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, +so a transition allocates nothing; XState builds a fresh snapshot per event. + +## 2. Compose / synced machines (`tests/compose.ts`) + +The cross-region machinery `compose` adds, scaled by member count. No competitor +column: neither XState nor Zag has a first-class orthogonal-region primitive that +maps to `combine`/`sync`, so there's nothing equivalent to time (**n/a ᶠ** for +both). + +**A. combine** — one value-deduped `Selection` derived across M members but reading +only `m0`. It re-evaluates on _any_ member change but fires its listener only when +`m0` changes. **B. sync** — a coarse cross-region rule that wakes on _any_ member +change (the O(members) path by design). + +| Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | +| ------- | ----------------------: | -------------------: | ------ | ----- | +| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | +| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | +| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | + +→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. + +> A third "chain" sub-test (a sync rule that `send()`s downstream every change) was +> removed: under a tight loop it shows superlinear slowdown. That's a real +> `compose.sync` + cross-machine-send interaction worth investigating on its own, +> not a benchmark-tuning artifact — see the note in `tests/compose.ts`. + +## 3. Computed (`tests/computed.ts`) + +The most machinery-heavy subsystem — read-key tracking via proxies, memoization +against a dep snapshot, glitch-free computed→computed chains. This is a subsystem +profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Zag's +`send` is async (**n/a ᵃ**), so neither has a comparable primitive to time. + +| Scenario | Dunky (ops/sec) | XState | Zag | +| ------------------------------------ | --------------: | ------ | ----- | +| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | + +→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means +you only pay the recompute when an input you actually read changes. + +## 4. Engine hot paths (`tests/engine.ts`) + +The parts of `send` that do real statechart work (everything else in the suite +stays in one state and only mutates context). These probe Dunky internals in +isolation — there's no comparable isolated path to time in XState (**n/a ᶠ**), and +Zag's `send` is async (**n/a ᵃ**). + +| Scenario | Dunky (ops/sec) | XState | Zag | +| -------------------------------------- | --------------: | ------ | ----- | +| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full +state transitions with entry/exit actions, and effect boot/cleanup every +transition all stay in the same order of magnitude as a bare `send`. + +## 5. Construction cost (`tests/construct.ts`) + +Wall-clock to build + `start()` N machines, no events sent (matches a real mount). +Synchronous for all three, so it's a fair three-way table. Median of 5 passes, JIT +warmed first. + +| Build + start | Dunky (µs/machine) | XState | Zag | +| ------------- | -----------------: | -----: | ---: | +| 10 000 | 1.58 | 1.35 | 5.06 | + +→ Construction is the one axis where Dunky **doesn't** win — XState spins up +~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.2× faster than Zag's per-field reactive cells. + +## 6. Memory per machine (`tests/memory.ts`) + +Build 5000 machines, hold them live, report retained heap per machine (`heapMB()` +double-GCs before sampling). Two context widths — **thin** (2 fields) and **fat** +(64 fields) — because the whole point of the plain-object model is that memory +stays ~flat in field count. Rows below are the **written** mode (one `hit` each — +the footprint a churny app actually pays). + +| Context | Dunky (KB/machine) | XState | Zag | +| -------- | -----------------: | -----: | ------: | +| 2-field | 3.85 | 3.61 | 8.92 | +| 64-field | 4.35 | 4.10 | **134** | + +→ Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with +the data you store, not with a per-field cell. **Zag is the contrast**: one reactive +cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than +Dunky. + +**Idle vs written.** Dunky owns its context copy from construction and mutates +it in place forever, so its idle and written footprints match by design — while a +lazy-copy scheme steps up once writes start: + +| 64-field, 5000 machines | Dunky | XState | Zag | +| ----------------------- | ----: | -----: | --: | +| Idle (never written) | 4.35 | 3.55 | 130 | +| Written (1 event each) | 4.35 | 4.10 | 134 | + +→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, +so its written row grows. + +## 7. React rendering (`tests/rendering/`) + +The thing that actually hurts in an app — how many React components render — under +jsdom. A list of N rows, 50 highlight moves. Two numbers: **rows woken / move** +(the fine-grained payoff) and **wall-clock** per phase. Strategies: `selector` +(shared machine + `useSelector` per row), `naive` (whole-snapshot — the +anti-pattern), `core/instance` (one machine per row), plus `xstate/selector` and +`zag/instance`. + +List of 1000 rows: + +| Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | +| -------------------- | ----------------: | ---------: | ------------------: | +| Dunky/instance | **2** | 3.6 | **2.4** | +| Dunky/selector | 2 | 4.9 | 3.8 | +| xstate/selector | 2 | 3.7 | 4.3 | +| zag/instance | 2 | 3.9 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.5 | 38.3 | + +→ Every properly-set-up engine wakes only the **2** rows that changed (vs. the +naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and +~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× +faster than XState**. + +Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the +microtask-batched `send` can't be timed under a synchronous `flushSync` loop, so +only its row-count is comparable. + +## Where this actually matters + +The loads here (and in the demo) are deliberately extreme to force the engines to +diverge. That only reflects real software when a single view holds **thousands of +independently-stateful, live-updating cells in one frame budget**. That's not a +hypothetical — these are whole product categories: + +| Real workload | Example products | Where the count comes from | Live cells | +| ----------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | ---------: | +| **Full L2 order book / DOM ladder** | Bookmap, Sierra Chart, ATAS | 500–2,000 price levels per book × several books, each level a live cell | 3k–15k | +| **Options chain / vol surface** | thinkorswim, Tastytrade, OptionStrat | hundreds of strikes × calls+puts × 8–16 live fields (bid/ask/IV/Δ/Γ/Θ/V) | 5k–20k | +| **Screeners / heatmaps** | TradingView screener, CoinMarketCap, Finviz | 1,000–5,000 symbols × a few live fields (price, %chg, flash, sparkline) | 3k–15k | +| **Live-data spreadsheets** | Excel/Sheets + market add-ins, Bloomberg BQNT | a visible sheet is 5k–20k cells, many bound to streaming feeds + recompute | 5k–20k | +| **Observability walls** | Grafana, Datadog, Netdata | hundreds of panels × series, or a per-second host grid | 5k–50k | +| **Live log / trace tails** | Datadog Live Tail, Kibana, Sentry | streaming rows, each a tiny stateful unit, into a long virtualized buffer | 5k–50k | +| **Dense collaborative canvases** | Figma, tldraw | 5k–50k nodes; a `pointermove` / cursor stream fans out to all visible shapes | 5k–50k | +| **NOC / k8s / network walls** | k9s, Lens, traffic grids | thousands of pods/nodes/flows, each a live cell | 5k–20k | + +The sharpest target is a **dense live financial surface** (full order book or +options chain): genuinely 5k–20k live cells, updating tens of thousands of times a +second, often needed on **web _and_ native** from one codebase, where flat +per-cell memory matters — every property the benchmark measures pays at once. + +> **What does _not_ need this:** a handful-of-symbols chart view, a typical SaaS +> dashboard, a chat app — dozens to a few hundred live elements. The engine works +> there too, but so does anything; at that scale you'd pick it for the +> agnostic-render and bundle/memory reasons, not the throughput these tables show. diff --git a/benchmark/competitors.ts b/benchmark/ts/competitors.ts similarity index 100% rename from benchmark/competitors.ts rename to benchmark/ts/competitors.ts diff --git a/benchmark/demo/CHANGELOG.md b/benchmark/ts/demo/CHANGELOG.md similarity index 100% rename from benchmark/demo/CHANGELOG.md rename to benchmark/ts/demo/CHANGELOG.md diff --git a/benchmark/demo/README.md b/benchmark/ts/demo/README.md similarity index 99% rename from benchmark/demo/README.md rename to benchmark/ts/demo/README.md index eac46ff..138c5e8 100644 --- a/benchmark/demo/README.md +++ b/benchmark/ts/demo/README.md @@ -9,7 +9,7 @@ panels' backlogs grow as the load climbs. ```bash pnpm benchmark:demo # from the repo root # or -cd benchmark/demo && pnpm dev +cd benchmark/ts/demo && pnpm dev ``` Then open the printed URL (default ). It's idle until you diff --git a/benchmark/demo/index.html b/benchmark/ts/demo/index.html similarity index 100% rename from benchmark/demo/index.html rename to benchmark/ts/demo/index.html diff --git a/benchmark/demo/package.json b/benchmark/ts/demo/package.json similarity index 100% rename from benchmark/demo/package.json rename to benchmark/ts/demo/package.json diff --git a/benchmark/demo/src/app.tsx b/benchmark/ts/demo/src/app.tsx similarity index 100% rename from benchmark/demo/src/app.tsx rename to benchmark/ts/demo/src/app.tsx diff --git a/benchmark/demo/src/engines.ts b/benchmark/ts/demo/src/engines.ts similarity index 100% rename from benchmark/demo/src/engines.ts rename to benchmark/ts/demo/src/engines.ts diff --git a/benchmark/demo/src/feed.ts b/benchmark/ts/demo/src/feed.ts similarity index 100% rename from benchmark/demo/src/feed.ts rename to benchmark/ts/demo/src/feed.ts diff --git a/benchmark/demo/src/main.tsx b/benchmark/ts/demo/src/main.tsx similarity index 100% rename from benchmark/demo/src/main.tsx rename to benchmark/ts/demo/src/main.tsx diff --git a/benchmark/demo/src/metrics.ts b/benchmark/ts/demo/src/metrics.ts similarity index 100% rename from benchmark/demo/src/metrics.ts rename to benchmark/ts/demo/src/metrics.ts diff --git a/benchmark/demo/src/panels.tsx b/benchmark/ts/demo/src/panels.tsx similarity index 100% rename from benchmark/demo/src/panels.tsx rename to benchmark/ts/demo/src/panels.tsx diff --git a/benchmark/demo/vite.config.ts b/benchmark/ts/demo/vite.config.ts similarity index 91% rename from benchmark/demo/vite.config.ts rename to benchmark/ts/demo/vite.config.ts index f9db889..a8294ee 100644 --- a/benchmark/demo/vite.config.ts +++ b/benchmark/ts/demo/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ // the engine package is workspace-linked; aliasing straight to its `src` // means Vite serves the real TS source (no build step, edits hot-reload) alias: { - '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine': resolve(__dirname, '../../../packages/core/src'), }, dedupe: ['react', 'react-dom'], }, diff --git a/benchmark/index.ts b/benchmark/ts/index.ts similarity index 93% rename from benchmark/index.ts rename to benchmark/ts/index.ts index 05db838..f8d7f25 100644 --- a/benchmark/index.ts +++ b/benchmark/ts/index.ts @@ -7,7 +7,7 @@ * The single command passes --expose-gc (memory needs it; harmless elsewhere). * * Each bench is also exported standalone if you want to run one in isolation: - * node --expose-gc --import tsx -e "import('./benchmark/tests/memory').then(m=>m.runMemory())" + * node --expose-gc --import tsx -e "import('./benchmark/ts/tests/memory').then(m=>m.runMemory())" * (or import the run* fn from its file). The rendering bench bootstraps jsdom * itself, so it's safe to run alone too. * diff --git a/benchmark/package.json b/benchmark/ts/package.json similarity index 100% rename from benchmark/package.json rename to benchmark/ts/package.json diff --git a/benchmark/report.ts b/benchmark/ts/report.ts similarity index 100% rename from benchmark/report.ts rename to benchmark/ts/report.ts diff --git a/benchmark/tests/compose.ts b/benchmark/ts/tests/compose.ts similarity index 100% rename from benchmark/tests/compose.ts rename to benchmark/ts/tests/compose.ts diff --git a/benchmark/tests/computed.ts b/benchmark/ts/tests/computed.ts similarity index 100% rename from benchmark/tests/computed.ts rename to benchmark/ts/tests/computed.ts diff --git a/benchmark/tests/construct.ts b/benchmark/ts/tests/construct.ts similarity index 100% rename from benchmark/tests/construct.ts rename to benchmark/ts/tests/construct.ts diff --git a/benchmark/tests/engine.ts b/benchmark/ts/tests/engine.ts similarity index 100% rename from benchmark/tests/engine.ts rename to benchmark/ts/tests/engine.ts diff --git a/benchmark/tests/fan-out.ts b/benchmark/ts/tests/fan-out.ts similarity index 100% rename from benchmark/tests/fan-out.ts rename to benchmark/ts/tests/fan-out.ts diff --git a/benchmark/tests/memory.ts b/benchmark/ts/tests/memory.ts similarity index 100% rename from benchmark/tests/memory.ts rename to benchmark/ts/tests/memory.ts diff --git a/benchmark/tests/rendering/bench.tsx b/benchmark/ts/tests/rendering/bench.tsx similarity index 100% rename from benchmark/tests/rendering/bench.tsx rename to benchmark/ts/tests/rendering/bench.tsx diff --git a/benchmark/tests/rendering/run.ts b/benchmark/ts/tests/rendering/run.ts similarity index 100% rename from benchmark/tests/rendering/run.ts rename to benchmark/ts/tests/rendering/run.ts diff --git a/benchmark/tsconfig.json b/benchmark/ts/tsconfig.json similarity index 66% rename from benchmark/tsconfig.json rename to benchmark/ts/tsconfig.json index c85f0b0..cfe49c6 100644 --- a/benchmark/tsconfig.json +++ b/benchmark/ts/tsconfig.json @@ -1,16 +1,16 @@ { // Typecheck the benchmark suite + the live demo. They run via tsx/vite (which // don't typecheck), so this is the only thing that catches type errors here. - "extends": "../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { // the base sets no `jsx` (it's per-project); the benchmark is React-flavored "jsx": "react-jsx", // paths in `extends` resolve relative to THIS file, so redeclare them "paths": { - "@dunky.dev/state-machine": ["../packages/core/src"], - "@dunky.dev/react-state-machine": ["../packages/react/src"], - "@dunky.dev/native-state-machine": ["../packages/native/src"], - "@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"] + "@dunky.dev/state-machine": ["../../packages/core/src"], + "@dunky.dev/react-state-machine": ["../../packages/react/src"], + "@dunky.dev/native-state-machine": ["../../packages/native/src"], + "@dunky.dev/state-machine-utils": ["../../packages/shared/utils/src"] }, // the benchmark touches Node globals (process, global) + DOM (jsdom, react-dom) "types": ["@types/node", "@types/react", "@types/react-dom", "@types/jsdom"] diff --git a/package.json b/package.json index 45831a4..d62b270 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "benchmark": "pnpm -C benchmark benchmark", - "benchmark:demo": "pnpm -C benchmark/demo dev", + "benchmark": "pnpm -C benchmark/ts benchmark", + "benchmark:demo": "pnpm -C benchmark/ts/demo dev", "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 111dde0..a75981b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,14 +45,14 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) - benchmark: + benchmark/ts: dependencies: '@dunky.dev/react-state-machine': specifier: workspace:^ - version: link:../packages/react + version: link:../../packages/react '@dunky.dev/state-machine': specifier: workspace:^ - version: link:../packages/core + version: link:../../packages/core devDependencies: '@types/jsdom': specifier: ^21.1.7 @@ -94,11 +94,11 @@ importers: specifier: ^5.32.0 version: 5.32.1 - benchmark/demo: + benchmark/ts/demo: dependencies: '@dunky.dev/state-machine': specifier: workspace:^ - version: link:../../packages/core + version: link:../../../packages/core devDependencies: '@vitejs/plugin-react': specifier: ^6.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66765af..ec93898 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,8 @@ packages: - packages/**/* - sandbox/**/* - - benchmark - - benchmark/demo + - benchmark/ts + - benchmark/ts/demo - website allowBuilds: esbuild: true diff --git a/tsconfig.build.json b/tsconfig.build.json index 220594f..116a41d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,7 +11,7 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, - { "path": "./benchmark" }, + { "path": "./benchmark/ts" }, { "path": "./sandbox/shared" } ] } diff --git a/website/package.json b/website/package.json index 0882d10..3f2b368 100644 --- a/website/package.json +++ b/website/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "astro dev", "build": "astro build && pnpm build:benchmark-demo", - "build:benchmark-demo": "BASE_PATH=/state-machine/benchmark/demo/ OUT_DIR=../../website/dist/state-machine/benchmark/demo pnpm -C ../benchmark/demo build", + "build:benchmark-demo": "BASE_PATH=/state-machine/benchmark/demo/ OUT_DIR=../../../website/dist/state-machine/benchmark/demo pnpm -C ../benchmark/ts/demo build", "preview": "astro preview", "astro": "astro" }, diff --git a/website/src/content/docs/rust/index.mdx b/website/src/content/docs/rust/index.mdx index dd5cc02..f6e2924 100644 --- a/website/src/content/docs/rust/index.mdx +++ b/website/src/content/docs/rust/index.mdx @@ -54,3 +54,11 @@ dunky-core = { git = "https://github.com/dunky-dev/state-machine" } side gets its twin on the other. - What only one engine has stays out of the shared spec: TS-only typing (`setup()`), and Rust-only authoring (derives, timers as commands). + +## Benchmarks + +Each engine has its own suite, on the same scenarios: +[`benchmark/ts`](https://github.com/dunky-dev/state-machine/tree/main/benchmark/ts) (the +TS engine against XState and Zag; see [Benchmark](/benchmark)) and +[`benchmark/rust`](https://github.com/dunky-dev/state-machine/tree/main/benchmark/rust) +(the Rust engine, natively: `cargo bench -p dunky-benchmark`). From dbd38d4c91e61757b439f1e563a5906af2089a51 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 20:08:58 +0200 Subject: [PATCH 26/28] perf(rust): no allocation in the steady state, and a 60 fps frame budget - 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 --- benchmark/README.md | 2 +- benchmark/rust/README.md | 141 ++++++++++++++++++------------- benchmark/rust/benches/engine.rs | 107 ++++++++++++++++++++++- crates/core/src/computed.rs | 28 +++--- crates/core/src/config.rs | 32 ++++--- crates/core/src/selection.rs | 14 ++- crates/core/tests/computed.rs | 17 ++++ 7 files changed, 248 insertions(+), 93 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index f7fc134..a9d22c6 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -5,7 +5,7 @@ Dunky has two engines that follow one spec, and each has its own suite: | Suite | What it measures | Run | | ----------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------- | | [`ts/`](./ts) | The TS engine (`@dunky.dev/state-machine`) against XState and Zag, plus React rendering and a live demo | `pnpm benchmark` | -| [`rust/`](./rust) | The Rust engine (`dunky-core`), natively, on the same scenarios | `cargo bench -p dunky-benchmark` | +| [`rust/`](./rust) | The Rust engine (`dunky-core`), natively, on the same scenarios, plus a 60 fps frame budget | `cargo bench -p dunky-benchmark` | The Rust rows are twins of TS rows, so a change to one engine can be measured against the same work on the other. diff --git a/benchmark/rust/README.md b/benchmark/rust/README.md index 6e4c74d..4bb9ff1 100644 --- a/benchmark/rust/README.md +++ b/benchmark/rust/README.md @@ -1,66 +1,89 @@ # Rust benchmark suite -The Rust engine (`dunky-core`) measured natively, on its own. Each row is the twin of a -row in the TS suite ([`../ts`](../ts/README.md)): the same machine shape and the same -work, so the two engines can be tuned against the same scenarios. - -Numbers below are the average of two runs (release build, Apple M5 Pro). Absolute -figures vary by machine and thermal state — **run it yourself**. - -## Running +The Rust engine (`dunky-core`), measured natively. Release build, Apple M5 Pro; medians are +the average of two runs, and the frame table's p99 and worst columns take the **higher** of +the two runs. Absolute figures vary by machine and thermal state — **run it yourself**. ```bash cargo bench -p dunky-benchmark ``` -A plain `cargo bench` target with no extra dependency: ops/sec is the median of 5 -samples of ~100 ms after a 100 ms warmup; memory counts live heap bytes with a counting -allocator. The machines are in [`src/lib.rs`](./src/lib.rs), the runner in -[`benches/engine.rs`](./benches/engine.rs). - -## Results - -The **TS engine** column is the same row in [`../ts`](../ts/README.md), for reference -only: native Rust and JS on V8 are different runtimes, so this is not a contest. - -### Throughput and fan-out - -| Scenario | Rust (ops/sec) | TS engine | -| ----------------------------- | -------------: | --------: | -| One event (observed field) | **52.3 M** | 11.6 M | -| Unobserved write, 1,000 cells | **79.6 M** | 5.4 M | -| Unobserved write, 5,000 cells | **76.9 M** | 4.4 M | -| 1 change, 64 observers | **4.0 M** | — | - -A Rust context is a typed struct, with at most 64 fields, so the TS suite's 5,000-field -fan-out has no Rust twin. Its Rust row uses the 64-field maximum: one value-deduped -selection per field. - -### Computed - -| Scenario | Rust (ops/sec) | TS engine | -| ------------------------------------ | -------------: | --------: | -| Cached read (no change) | **355.7 M** | 44.0 M | -| Fine-grain (change unread, re-read) | **107.5 M** | 10.7 M | -| Recompute (change read field) | **42.0 M** | 4.9 M | -| 4-deep chain (change root, read tip) | **11.8 M** | 1.5 M | - -### Engine paths - -| Scenario | Rust (ops/sec) | TS engine | -| --------------------------------------------- | -------------: | --------: | -| Guard fallthrough, 2 candidates | **157.6 M** | 5.2 M | -| Guard fallthrough, 8 candidates | **62.4 M** | 4.5 M | -| Guard fallthrough, 32 candidates | **16.7 M** | 3.2 M | -| State churn (exit + entry every event) | **87.4 M** | 8.8 M | -| Effect churn (boot + cleanup each transition) | **60.3 M** | 8.2 M | - -### Construction and memory - -| Scenario | Rust | TS engine | -| ----------------------------------------- | ----------: | --------: | -| Build + start, per machine (10,000) | **23 ns** | 1.58 µs | -| Memory, 2-field context, written (5,000) | **0.35 KB** | 3.85 KB | -| Memory, 64-field context, written (5,000) | **0.59 KB** | 4.35 KB | - -Rendering and composition are TS-only (React, `compose`), so they have no Rust rows. +## It does not cost a frame + +At 60 fps one frame is **16.67 ms**. Each workload below runs inside one frame, timed frame +by frame over **600 frames** (10 seconds of 60 fps): + +| Workload per frame | Median (of 16.67 ms) | p99 | Worst | Allocations | +| ---------------------------------------- | -------------------: | -------: | -------: | ----------: | +| 1,000 machines, 1 event each | 0.013 ms (0.1%) | 0.041 ms | 0.064 ms | **0** | +| 10,000 machines, 1 event each | 0.135 ms (0.8%) | 0.205 ms | 0.233 ms | **0** | +| 1 machine, 10,000 events | 0.103 ms (0.6%) | 0.123 ms | 0.129 ms | **0** | +| 1,000 transitions (exit, entry, effects) | 0.017 ms (0.1%) | 0.021 ms | 0.039 ms | **0** | +| 1,000 computed recomputes | 0.014 ms (0.1%) | 0.017 ms | 0.025 ms | **0** | +| 64 observers, 1,000 field changes | 0.251 ms (1.5%) | 0.313 ms | 0.331 ms | **0** | + +→ **The heaviest workload uses 2% of a frame in its worst frame.** 10,000 machines +reacting to an event every frame take under a quarter of a millisecond, leaving more than +98% of the frame for rendering and the rest of the app. + +→ **Zero allocations per frame.** The steady state allocates nothing — no events, no +notifications, no recomputes — so the engine adds no allocator pauses and no jitter. + +→ **~1.5 million events fit in one frame** on a single machine (93 M events/sec ÷ 60). + +## Throughput + +| Scenario | ops/sec | ns/op | +| ----------------------------- | ---------: | ----: | +| One event (observed field) | **93.4 M** | 10.7 | +| Unobserved write, 1,000 cells | 80.5 M | 12.4 | +| Unobserved write, 5,000 cells | 79.5 M | 12.6 | +| 1 change, 64 observers | 3.9 M | 260.0 | + +"Observed" means a value-deduped selection watches the field; an unobserved write wakes no +one. A Rust context is a typed struct with at most 64 fields, so the widest fan-out is one +selection on each of 64 fields: one change wakes and re-checks all 64. + +## Computed values + +| Scenario | ops/sec | ns/op | +| ------------------------------------ | ----------: | ----: | +| Cached read (no change) | **364.5 M** | 2.7 | +| Fine-grain (change unread, re-read) | 102.4 M | 9.8 | +| Recompute (change read field) | 68.4 M | 14.6 | +| 4-deep chain (change root, read tip) | 18.7 M | 53.6 | + +A cached read is a stamp comparison. Changing a field the value does not read keeps it +cached; a recompute reuses the value's memory in place when nothing else holds it. + +## Engine paths + +| Scenario | ops/sec | ns/op | +| --------------------------------------------- | ----------: | ----: | +| Guard fallthrough, 2 candidates | **147.8 M** | 6.8 | +| Guard fallthrough, 8 candidates | 61.9 M | 16.2 | +| Guard fallthrough, 32 candidates | 15.7 M | 63.8 | +| State churn (exit + entry every event) | 92.4 M | 10.8 | +| Effect churn (boot + cleanup each transition) | 61.9 M | 16.2 | + +## Construction and memory + +| Scenario | Value | +| ----------------------------------------- | ----------: | +| Build + start, per machine (10,000) | **23 ns** | +| Memory, 2-field context, written (5,000) | **0.35 KB** | +| Memory, 64-field context, written (5,000) | **0.59 KB** | + +→ 10,000 machines cost about 3.5 MB with a 2-field context, and build in about 0.23 ms. + +## How it measures + +- **ops/sec**: the median of 5 samples of ~100 ms, after a 100 ms warmup. +- **Frames**: each workload runs 60 warmup frames, then 600 timed frames, each timed on + its own; the table shows the median, the 99th percentile and the slowest frame. +- **Memory and allocations**: a counting global allocator; memory is live heap bytes per + machine, allocations are counted across the 600 frames. +- The machines are in [`src/lib.rs`](./src/lib.rs), the runner in + [`benches/engine.rs`](./benches/engine.rs). Each scenario is the twin of one in the TS + suite ([`../ts`](../ts/README.md)), so a change to one engine can be checked on the + same work. diff --git a/benchmark/rust/benches/engine.rs b/benchmark/rust/benches/engine.rs index 520441c..3078dcb 100644 --- a/benchmark/rust/benches/engine.rs +++ b/benchmark/rust/benches/engine.rs @@ -13,14 +13,16 @@ use std::time::{Duration, Instant}; use dunky_benchmark::*; use dunky_core::Machine; -/// Counts live heap bytes, for the memory rows. +/// Counts live heap bytes (the memory rows) and allocations (the frame rows). struct Counting; static LIVE: AtomicUsize = AtomicUsize::new(0); +static ALLOCS: AtomicUsize = AtomicUsize::new(0); unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { LIVE.fetch_add(layout.size(), Ordering::Relaxed); + ALLOCS.fetch_add(1, Ordering::Relaxed); unsafe { System.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { @@ -83,9 +85,109 @@ fn row(name: &str, op: impl FnMut()) { println!("| {name:<46} | {:>10} | {:>8.1} |", human(ops), 1e9 / ops); } +/// One frame at 60 fps. +const FRAME_MS: f64 = 1000.0 / 60.0; +/// Frames timed per workload (10 s of 60 fps), after a warmup of 60. +const FRAMES: usize = 600; + +/// Time `work` (one frame's worth of machine work) frame by frame: the median, p99 and +/// worst frame, as a share of the 16.67 ms budget, and the allocations per frame. +fn frame(name: &str, mut work: impl FnMut()) { + for _ in 0..60 { + work(); + } + let mut times = Vec::with_capacity(FRAMES); + let allocs_before = ALLOCS.load(Ordering::Relaxed); + for _ in 0..FRAMES { + let start = Instant::now(); + work(); + times.push(start.elapsed().as_secs_f64() * 1e3); + } + let allocs = (ALLOCS.load(Ordering::Relaxed) - allocs_before) as f64 / FRAMES as f64; + times.sort_by(f64::total_cmp); + let median = times[FRAMES / 2]; + let p99 = times[FRAMES * 99 / 100]; + let worst = times[FRAMES - 1]; + let pct = |ms: f64| ms / FRAME_MS * 100.0; + println!( + "| {name:<40} | {median:>6.3} ms ({:>4.1}%) | {p99:>6.3} ms ({:>4.1}%) | {worst:>6.3} ms ({:>4.1}%) | {allocs:>6.1} |", + pct(median), + pct(p99), + pct(worst), + ); +} + +fn frame_budget() { + println!("\n### Frame budget at 60 fps (one frame = {FRAME_MS:.2} ms, {FRAMES} frames each)"); + println!( + "| {:<40} | {:>17} | {:>17} | {:>17} | {:>6} |", + "workload per frame", "median", "p99", "worst", "allocs" + ); + println!( + "| {:-<40} | {:->17} | {:->17} | {:->17} | {:->6} |", + "", "", "", "", "" + ); + + let cell = cell_config(); + for n in [1_000, 10_000] { + let cells: Vec<_> = (0..n).map(|_| observed_cell(&cell)).collect(); + let label = if n == 1_000 { "1,000" } else { "10,000" }; + frame(&format!("{label} machines, 1 event each"), || { + for c in &cells { + c.send(CellEvent::Hit); + } + }); + } + let one = observed_cell(&cell); + frame("1 machine, 10,000 events", || { + for _ in 0..10_000 { + one.send(CellEvent::Hit); + } + }); + let churn = effect_churn_config(); + let machines: Vec<_> = (0..1_000) + .map(|_| { + let m = Machine::new(&churn); + m.start(); + m + }) + .collect(); + frame("1,000 transitions (exit, entry, effects)", || { + for m in &machines { + m.send(Go::Go); + } + }); + let (config, keys) = computed_config(); + let calc = Machine::new(&config); + calc.start(); + frame("1,000 computed recomputes", || { + for _ in 0..1_000 { + calc.send(ComputedEvent::BumpA); + black_box(calc.computed(keys.sum)); + } + }); + let wide = Machine::new(&wide_config()); + wide.start(); + for k in 0..64 { + std::mem::forget( + wide.select(move |v| wide_field(v.context(), k)) + .subscribe(|_| bump()), + ); + } + let mut i = 0; + frame("64 observers, 1,000 field changes", || { + for _ in 0..1_000 { + wide.send(WideEvent::Set { key: i % 64 }); + i += 1; + } + }); +} + fn main() { println!("Rust engine benchmark (dunky-core, native, release build)"); + frame_budget(); + section("Throughput and fan-out"); let cell = cell_config(); let m = observed_cell(&cell); @@ -93,7 +195,8 @@ fn main() { for n in [1_000, 5_000] { let cells: Vec<_> = (0..n).map(|_| observed_cell(&cell)).collect(); let mut i = 0; - row(&format!("unobserved write, {n} cells"), || { + let label = if n == 1_000 { "1,000" } else { "5,000" }; + row(&format!("unobserved write, {label} cells"), || { cells[i % n].send(CellEvent::Miss); i += 1; }); diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs index 08304e9..fdec4ed 100644 --- a/crates/core/src/computed.rs +++ b/crates/core/src/computed.rs @@ -14,16 +14,15 @@ use std::rc::Rc; use crate::machine::{Core, Inner}; use crate::traits::{Context, Types}; -pub(crate) type ComputedEval = Rc Fn(&ComputedParams<'a, T>) -> Rc>; - -pub(crate) enum ComputedSource { - Fn(ComputedEval), -} +/// Evaluate a computed value into its slot, and say whether it changed (`PartialEq`). +/// The slot's value is reused in place when nothing else holds it, so a recompute +/// allocates nothing. +pub(crate) type ComputedEval = + Rc Fn(&ComputedParams<'a, T>, &mut Option>) -> bool>; pub(crate) struct ComputedDef { pub(crate) name: &'static str, - pub(crate) eval: ComputedSource, - pub(crate) eq: fn(&dyn Any, &dyn Any) -> bool, + pub(crate) eval: ComputedEval, } /// A typed handle to one computed value, returned by `ConfigBuilder::computed`. @@ -178,9 +177,10 @@ fn fresh(core: &Core, inner: &Inner, id: usize) -> Option(core: &Core, inner: &Inner, id: usize) -> Rc { let def = &inner.config.0.computed[id]; - let ComputedSource::Fn(eval) = &def.eval; let slot_cell = &core.computed.slots[id]; - let buffer = { + // The value leaves the slot while it is evaluated: the definition writes the new one + // into it. + let (buffer, mut value) = { let mut slot = slot_cell.borrow_mut(); if slot.evaluating { panic!("[machine] computed \"{}\" depends on itself", def.name); @@ -188,7 +188,7 @@ fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc(&'s RefCell); @@ -214,16 +214,12 @@ fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc !(def.eq)(&**prev, &*next), - None => true, - }; + slot.value = value; if changed { - slot.value = Some(next); slot.changed_at = core.tick; } slot.validated_at = core.tick; diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index ab24a25..81d6eea 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -2,7 +2,7 @@ use std::any::Any; use std::collections::HashMap; use std::rc::Rc; -use crate::computed::{ComputedDef, ComputedKey, ComputedParams, ComputedSource}; +use crate::computed::{ComputedDef, ComputedKey, ComputedParams}; use crate::params::{ActionParams, GuardParams}; use crate::traits::{Context, EventEnum, Field, StateEnum, Types}; @@ -518,10 +518,25 @@ impl ConfigBuilder { let id = self.inner.computed.len(); self.inner.computed.push(ComputedDef { name, - eval: ComputedSource::Fn(Rc::new(move |p: &ComputedParams<'_, T>| { - Rc::new(f(p)) as Rc - })), - eq: eq_any::, + eval: Rc::new( + move |p: &ComputedParams<'_, T>, slot: &mut Option>| { + let next = f(p); + if let Some(rc) = slot { + if let Some(old) = Rc::get_mut(rc).and_then(|old| old.downcast_mut::()) { + if *old == next { + return false; + } + *old = next; + return true; + } + if rc.downcast_ref::().is_some_and(|old| *old == next) { + return false; + } + } + *slot = Some(Rc::new(next)); + true + }, + ), }); ComputedKey::new(id) } @@ -643,13 +658,6 @@ impl Registry<'_, T> { } } -fn eq_any(a: &dyn Any, b: &dyn Any) -> bool { - match (a.downcast_ref::(), b.downcast_ref::()) { - (Some(a), Some(b)) => a == b, - _ => false, - } -} - /// The dev/prod rule for a missing named implementation, shared by every resolver. pub(crate) fn missing(kind: &str, name: &str) { let msg = format!("[machine] no {kind} \"{name}\""); diff --git a/crates/core/src/selection.rs b/crates/core/src/selection.rs index 0232b09..b4cccd1 100644 --- a/crates/core/src/selection.rs +++ b/crates/core/src/selection.rs @@ -77,10 +77,18 @@ impl Selection { Some(_) => true, None => false, }; - let next = Rc::new(next); - *prev.borrow_mut() = Some(next.clone()); + let current = { + let mut slot = prev.borrow_mut(); + // Nobody else holds the last value (no listener of this wake still runs): + // overwrite it in place, so a steady stream of changes allocates nothing. + match slot.as_mut().and_then(Rc::get_mut) { + Some(old) => *old = next, + None => *slot = Some(Rc::new(next)), + } + slot.clone().expect("value just set") + }; if seeded { - listener(&next); + listener(¤t); } })) } diff --git a/crates/core/tests/computed.rs b/crates/core/tests/computed.rs index 4e8bfd1..4ebd79f 100644 --- a/crates/core/tests/computed.rs +++ b/crates/core/tests/computed.rs @@ -364,3 +364,20 @@ fn a_context_write_does_not_recompute_a_state_only_computed() { assert_eq!(*m.computed(label), "state=busy"); assert_eq!(runs.count(), 2); } + +// Rust-only: a recompute reuses the value's memory when nothing else holds it, so a +// value a reader still holds must keep what it was. +#[test] +fn a_value_still_held_keeps_its_contents_after_a_recompute() { + let mut b = builder(Ctx::default()); + let double = b.computed("double", |p| p.context.n() * 2); + let m = Machine::new(&b.build()); + m.start(); + let held = m.computed(double); + m.send(Ev::BumpN); + assert_eq!(*held, 0); + assert_eq!(*m.computed(double), 2); + drop(held); + m.send(Ev::BumpN); + assert_eq!(*m.computed(double), 4); +} From 610eb34e332fda733ede63431f7dd036f4551d91 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 20:22:10 +0200 Subject: [PATCH 27/28] perf(rust): field selections, leaner notifies, and why TS stays off wasm 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, 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 --- Cargo.toml | 2 + benchmark/rust/README.md | 59 ++++---- benchmark/rust/benches/engine.rs | 33 ++++- benchmark/rust/src/lib.rs | 136 +++++++++---------- crates/core/SPEC.md | 11 +- crates/core/src/broadcast.rs | 41 ++++-- crates/core/src/config.rs | 4 +- crates/core/src/machine.rs | 80 ++++++++--- crates/core/src/selection.rs | 80 +++++++---- crates/core/src/traits.rs | 27 ++-- crates/core/tests/subscribe.rs | 58 +++++++- crates/macros/src/lib.rs | 3 +- crates/wasm/README.md | 8 ++ packages/wasm/README.md | 8 ++ website/src/content/docs/rust/machines.mdx | 2 + website/src/content/docs/rust/typescript.mdx | 8 ++ 16 files changed, 378 insertions(+), 182 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9d902e5..fad3fc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ js-sys = "0.3" serde-wasm-bindgen = "0.6" [profile.release] +# Function names in profiles of the benchmark (no effect on speed). +debug = "line-tables-only" opt-level = 3 lto = true codegen-units = 1 diff --git a/benchmark/rust/README.md b/benchmark/rust/README.md index 4bb9ff1..e5c2536 100644 --- a/benchmark/rust/README.md +++ b/benchmark/rust/README.md @@ -15,43 +15,44 @@ by frame over **600 frames** (10 seconds of 60 fps): | Workload per frame | Median (of 16.67 ms) | p99 | Worst | Allocations | | ---------------------------------------- | -------------------: | -------: | -------: | ----------: | -| 1,000 machines, 1 event each | 0.013 ms (0.1%) | 0.041 ms | 0.064 ms | **0** | -| 10,000 machines, 1 event each | 0.135 ms (0.8%) | 0.205 ms | 0.233 ms | **0** | -| 1 machine, 10,000 events | 0.103 ms (0.6%) | 0.123 ms | 0.129 ms | **0** | -| 1,000 transitions (exit, entry, effects) | 0.017 ms (0.1%) | 0.021 ms | 0.039 ms | **0** | -| 1,000 computed recomputes | 0.014 ms (0.1%) | 0.017 ms | 0.025 ms | **0** | -| 64 observers, 1,000 field changes | 0.251 ms (1.5%) | 0.313 ms | 0.331 ms | **0** | +| 1,000 machines, 1 event each | 0.014 ms (0.1%) | 0.031 ms | 0.072 ms | **0** | +| 10,000 machines, 1 event each | 0.151 ms (0.9%) | 0.215 ms | 0.435 ms | **0** | +| 1 machine, 10,000 events | 0.105 ms (0.6%) | 0.131 ms | 0.195 ms | **0** | +| 1,000 transitions (exit, entry, effects) | 0.017 ms (0.1%) | 0.026 ms | 0.048 ms | **0** | +| 1,000 computed recomputes | 0.015 ms (0.1%) | 0.019 ms | 0.033 ms | **0** | +| 64 observers, 1,000 field changes | 0.044 ms (0.3%) | 0.060 ms | 0.069 ms | **0** | -→ **The heaviest workload uses 2% of a frame in its worst frame.** 10,000 machines -reacting to an event every frame take under a quarter of a millisecond, leaving more than -98% of the frame for rendering and the rest of the app. +→ **No workload goes past 3% of a frame, even in its worst frame.** 10,000 machines +reacting to an event every frame take 0.15 ms at the median, leaving more than 97% of the +frame for rendering and the rest of the app. → **Zero allocations per frame.** The steady state allocates nothing — no events, no notifications, no recomputes — so the engine adds no allocator pauses and no jitter. -→ **~1.5 million events fit in one frame** on a single machine (93 M events/sec ÷ 60). +→ **~1.6 million events fit in one frame** on a single machine (96 M events/sec ÷ 60). ## Throughput | Scenario | ops/sec | ns/op | | ----------------------------- | ---------: | ----: | -| One event (observed field) | **93.4 M** | 10.7 | -| Unobserved write, 1,000 cells | 80.5 M | 12.4 | -| Unobserved write, 5,000 cells | 79.5 M | 12.6 | -| 1 change, 64 observers | 3.9 M | 260.0 | +| One event (observed field) | **95.6 M** | 10.5 | +| Unobserved write, 1,000 cells | 97.6 M | 10.2 | +| Unobserved write, 5,000 cells | 97.7 M | 10.2 | +| 1 change, 64 observers | 22.6 M | 44.2 | -"Observed" means a value-deduped selection watches the field; an unobserved write wakes no -one. A Rust context is a typed struct with at most 64 fields, so the widest fan-out is one -selection on each of 64 fields: one change wakes and re-checks all 64. +"Observed" means a value-deduped selection watches the field (`select_field`); a write to +another field skips it with one bit test. A Rust context is a typed struct with at most 64 +fields, so the widest fan-out is one selection on each of 64 fields: one change re-checks +only the selection of the field that changed. ## Computed values | Scenario | ops/sec | ns/op | | ------------------------------------ | ----------: | ----: | -| Cached read (no change) | **364.5 M** | 2.7 | -| Fine-grain (change unread, re-read) | 102.4 M | 9.8 | -| Recompute (change read field) | 68.4 M | 14.6 | -| 4-deep chain (change root, read tip) | 18.7 M | 53.6 | +| Cached read (no change) | **344.1 M** | 2.9 | +| Fine-grain (change unread, re-read) | 105.0 M | 9.5 | +| Recompute (change read field) | 68.7 M | 14.6 | +| 4-deep chain (change root, read tip) | 18.0 M | 55.6 | A cached read is a stamp comparison. Changing a field the value does not read keeps it cached; a recompute reuses the value's memory in place when nothing else holds it. @@ -60,21 +61,21 @@ cached; a recompute reuses the value's memory in place when nothing else holds i | Scenario | ops/sec | ns/op | | --------------------------------------------- | ----------: | ----: | -| Guard fallthrough, 2 candidates | **147.8 M** | 6.8 | -| Guard fallthrough, 8 candidates | 61.9 M | 16.2 | -| Guard fallthrough, 32 candidates | 15.7 M | 63.8 | -| State churn (exit + entry every event) | 92.4 M | 10.8 | -| Effect churn (boot + cleanup each transition) | 61.9 M | 16.2 | +| Guard fallthrough, 2 candidates | **171.2 M** | 5.8 | +| Guard fallthrough, 8 candidates | 61.7 M | 16.2 | +| Guard fallthrough, 32 candidates | 16.4 M | 61.0 | +| State churn (exit + entry every event) | 88.2 M | 11.3 | +| Effect churn (boot + cleanup each transition) | 63.3 M | 15.8 | ## Construction and memory | Scenario | Value | | ----------------------------------------- | ----------: | | Build + start, per machine (10,000) | **23 ns** | -| Memory, 2-field context, written (5,000) | **0.35 KB** | -| Memory, 64-field context, written (5,000) | **0.59 KB** | +| Memory, 2-field context, written (5,000) | **0.36 KB** | +| Memory, 64-field context, written (5,000) | **0.60 KB** | -→ 10,000 machines cost about 3.5 MB with a 2-field context, and build in about 0.23 ms. +→ 10,000 machines cost about 3.6 MB with a 2-field context, and build in about 0.23 ms. ## How it measures diff --git a/benchmark/rust/benches/engine.rs b/benchmark/rust/benches/engine.rs index 3078dcb..84f21d5 100644 --- a/benchmark/rust/benches/engine.rs +++ b/benchmark/rust/benches/engine.rs @@ -1,6 +1,7 @@ //! The Rust engine's benchmark: the TS suite's engine scenarios, run natively. //! //! cargo bench -p dunky-benchmark +//! DUNKY_BENCH=guard cargo bench -p dunky-benchmark # only the rows that match //! //! Each row is the twin of a row in `benchmark/ts`, so the two READMEs compare the same //! work. ops/sec is the median of 5 samples of ~100 ms, after a 100 ms warmup. @@ -36,11 +37,21 @@ static ALLOC: Counting = Counting; const SAMPLES: usize = 5; const SAMPLE: Duration = Duration::from_millis(100); + +/// `DUNKY_BENCH_LONG=1` samples for 2 s each: long enough to attach a profiler. +fn sample_time() -> Duration { + if std::env::var_os("DUNKY_BENCH_LONG").is_some() { + Duration::from_secs(2) + } else { + SAMPLE + } +} const BATCH: u64 = 1_000; fn ops_per_sec(mut op: impl FnMut()) -> f64 { + let sample = sample_time(); let warm = Instant::now(); - while warm.elapsed() < SAMPLE { + while warm.elapsed() < sample { for _ in 0..BATCH { op(); } @@ -49,7 +60,7 @@ fn ops_per_sec(mut op: impl FnMut()) -> f64 { for _ in 0..SAMPLES { let start = Instant::now(); let mut n = 0u64; - while start.elapsed() < SAMPLE { + while start.elapsed() < sample { for _ in 0..BATCH { op(); } @@ -80,7 +91,15 @@ fn section(title: &str) { println!("| {:-<46} | {:->10} | {:->8} |", "", "", ""); } +/// The `DUNKY_BENCH` filter: run only the rows whose name contains it. +fn selected(name: &str) -> bool { + std::env::var("DUNKY_BENCH").map_or(true, |filter| name.contains(&filter)) +} + fn row(name: &str, op: impl FnMut()) { + if !selected(name) { + return; + } let ops = ops_per_sec(op); println!("| {name:<46} | {:>10} | {:>8.1} |", human(ops), 1e9 / ops); } @@ -93,6 +112,9 @@ const FRAMES: usize = 600; /// Time `work` (one frame's worth of machine work) frame by frame: the median, p99 and /// worst frame, as a share of the 16.67 ms budget, and the allocations per frame. fn frame(name: &str, mut work: impl FnMut()) { + if !selected(name) { + return; + } for _ in 0..60 { work(); } @@ -169,10 +191,7 @@ fn frame_budget() { let wide = Machine::new(&wide_config()); wide.start(); for k in 0..64 { - std::mem::forget( - wide.select(move |v| wide_field(v.context(), k)) - .subscribe(|_| bump()), - ); + std::mem::forget(wide_select(&wide, k).subscribe(|_| bump())); } let mut i = 0; frame("64 observers, 1,000 field changes", || { @@ -204,7 +223,7 @@ fn main() { let wide = Machine::new(&wide_config()); wide.start(); for k in 0..64 { - let sel = wide.select(move |v| wide_field(v.context(), k)); + let sel = wide_select(&wide, k); std::mem::forget(sel.subscribe(|_| bump())); } let mut i = 0; diff --git a/benchmark/rust/src/lib.rs b/benchmark/rust/src/lib.rs index a9588d5..08ef462 100644 --- a/benchmark/rust/src/lib.rs +++ b/benchmark/rust/src/lib.rs @@ -4,7 +4,7 @@ use std::cell::Cell; -use dunky_core::{ComputedKey, Config, Context, Event, Machine, State, Types}; +use dunky_core::{ComputedKey, Config, Context, Event, Machine, Selection, State, Types}; thread_local! { static SINK: Cell = const { Cell::new(0) }; @@ -63,7 +63,7 @@ pub fn cell_config() -> Config { pub fn observed_cell(config: &Config) -> Machine { let m = Machine::new(config); m.start(); - std::mem::forget(m.select(|v| v.context().value).subscribe(|_| bump())); + std::mem::forget(m.select_field(CellCtx::VALUE).subscribe(|_| bump())); m } @@ -151,73 +151,73 @@ impl Types for Wide { type Context = W64; } -/// Field `k` of the wide context (a typed context has no field by index). -pub fn wide_field(c: &W64, k: usize) -> u32 { +/// A selection of field `k` of the wide context (TS: `select.context(`f${k}`)`). +pub fn wide_select(m: &Machine, k: usize) -> Selection { match k % 64 { - 0 => c.f0, - 1 => c.f1, - 2 => c.f2, - 3 => c.f3, - 4 => c.f4, - 5 => c.f5, - 6 => c.f6, - 7 => c.f7, - 8 => c.f8, - 9 => c.f9, - 10 => c.f10, - 11 => c.f11, - 12 => c.f12, - 13 => c.f13, - 14 => c.f14, - 15 => c.f15, - 16 => c.f16, - 17 => c.f17, - 18 => c.f18, - 19 => c.f19, - 20 => c.f20, - 21 => c.f21, - 22 => c.f22, - 23 => c.f23, - 24 => c.f24, - 25 => c.f25, - 26 => c.f26, - 27 => c.f27, - 28 => c.f28, - 29 => c.f29, - 30 => c.f30, - 31 => c.f31, - 32 => c.f32, - 33 => c.f33, - 34 => c.f34, - 35 => c.f35, - 36 => c.f36, - 37 => c.f37, - 38 => c.f38, - 39 => c.f39, - 40 => c.f40, - 41 => c.f41, - 42 => c.f42, - 43 => c.f43, - 44 => c.f44, - 45 => c.f45, - 46 => c.f46, - 47 => c.f47, - 48 => c.f48, - 49 => c.f49, - 50 => c.f50, - 51 => c.f51, - 52 => c.f52, - 53 => c.f53, - 54 => c.f54, - 55 => c.f55, - 56 => c.f56, - 57 => c.f57, - 58 => c.f58, - 59 => c.f59, - 60 => c.f60, - 61 => c.f61, - 62 => c.f62, - 63 => c.f63, + 0 => m.select_field(W64::F0), + 1 => m.select_field(W64::F1), + 2 => m.select_field(W64::F2), + 3 => m.select_field(W64::F3), + 4 => m.select_field(W64::F4), + 5 => m.select_field(W64::F5), + 6 => m.select_field(W64::F6), + 7 => m.select_field(W64::F7), + 8 => m.select_field(W64::F8), + 9 => m.select_field(W64::F9), + 10 => m.select_field(W64::F10), + 11 => m.select_field(W64::F11), + 12 => m.select_field(W64::F12), + 13 => m.select_field(W64::F13), + 14 => m.select_field(W64::F14), + 15 => m.select_field(W64::F15), + 16 => m.select_field(W64::F16), + 17 => m.select_field(W64::F17), + 18 => m.select_field(W64::F18), + 19 => m.select_field(W64::F19), + 20 => m.select_field(W64::F20), + 21 => m.select_field(W64::F21), + 22 => m.select_field(W64::F22), + 23 => m.select_field(W64::F23), + 24 => m.select_field(W64::F24), + 25 => m.select_field(W64::F25), + 26 => m.select_field(W64::F26), + 27 => m.select_field(W64::F27), + 28 => m.select_field(W64::F28), + 29 => m.select_field(W64::F29), + 30 => m.select_field(W64::F30), + 31 => m.select_field(W64::F31), + 32 => m.select_field(W64::F32), + 33 => m.select_field(W64::F33), + 34 => m.select_field(W64::F34), + 35 => m.select_field(W64::F35), + 36 => m.select_field(W64::F36), + 37 => m.select_field(W64::F37), + 38 => m.select_field(W64::F38), + 39 => m.select_field(W64::F39), + 40 => m.select_field(W64::F40), + 41 => m.select_field(W64::F41), + 42 => m.select_field(W64::F42), + 43 => m.select_field(W64::F43), + 44 => m.select_field(W64::F44), + 45 => m.select_field(W64::F45), + 46 => m.select_field(W64::F46), + 47 => m.select_field(W64::F47), + 48 => m.select_field(W64::F48), + 49 => m.select_field(W64::F49), + 50 => m.select_field(W64::F50), + 51 => m.select_field(W64::F51), + 52 => m.select_field(W64::F52), + 53 => m.select_field(W64::F53), + 54 => m.select_field(W64::F54), + 55 => m.select_field(W64::F55), + 56 => m.select_field(W64::F56), + 57 => m.select_field(W64::F57), + 58 => m.select_field(W64::F58), + 59 => m.select_field(W64::F59), + 60 => m.select_field(W64::F60), + 61 => m.select_field(W64::F61), + 62 => m.select_field(W64::F62), + 63 => m.select_field(W64::F63), _ => unreachable!(), } } diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md index cf753a6..d2549a6 100644 --- a/crates/core/SPEC.md +++ b/crates/core/SPEC.md @@ -63,7 +63,10 @@ reporting. **context** (a struct). - Derives generate the glue: the state names and indices; one event kind per variant, which keys the handlers; and for the context, a patch builder that sets fields by name, - one handle per field, and a reader that records which fields a derived value reads. + one typed handle per field (`Field`: its bit and how to read it), and a reader + that records which fields a derived value reads. +- A selection of one field (`select_field`, the twin of TS `select.context(key)`) reads + through the field's handle. - A **config** is built once, with a builder, from the initial state and the seed context. It is cheap to share: every machine of the type uses the same config. - Guards, actions, effects and delays are inline closures or registered names; a name @@ -116,7 +119,11 @@ The contract is `packages/core/SPEC.md`'s. These guarantees are specific to the ## Performance guarantees -- Notifying observers in the steady state allocates nothing. +- The steady state allocates nothing: events, notifications, selections and recomputes + reuse their memory. +- A field selection wakes only on a write to its field (and a state selection only on a + state change): every other notification skips it with one bit test, in subscription + order. Which selections fire, and when, is exactly the contract's. - A derived value is fresh by a stamp comparison: a read re-reads and compares no input value. - One config per machine type, shared by every instance. diff --git a/crates/core/src/broadcast.rs b/crates/core/src/broadcast.rs index f9c1bad..4e117b4 100644 --- a/crates/core/src/broadcast.rs +++ b/crates/core/src/broadcast.rs @@ -3,6 +3,9 @@ use std::rc::{Rc, Weak}; type Listener = Rc; +/// Wakes on every notification. +pub(crate) const ALL: u64 = u64::MAX; + /// Payload-less one-to-all notify. /// /// Steady-state notifies allocate nothing: iteration runs over a cached snapshot, rebuilt @@ -10,8 +13,12 @@ type Listener = Rc; /// a dirty flag flips iteration to membership-checked mode, and a swapped snapshot (from a /// nested notify) counts as mid-pass churn too. pub(crate) struct Broadcast { - listeners: RefCell>, - snapshot: RefCell>, + /// Per listener: its id, the change bits it wakes on, and the listener. + listeners: RefCell>, + snapshot: RefCell>, + /// Bumped each time the snapshot is rebuilt, so a pass sees a nested rebuild with one + /// load per listener. + generation: Cell, dirty: Cell, next_id: Cell, } @@ -21,15 +28,22 @@ impl Broadcast { Rc::new(Self { listeners: RefCell::new(Vec::new()), snapshot: RefCell::new(Rc::from(Vec::new())), + generation: Cell::new(0), dirty: Cell::new(false), next_id: Cell::new(0), }) } pub(crate) fn add(self: &Rc, listener: Listener) -> Subscription { + self.add_waking(ALL, listener) + } + + /// A listener that skips notifications whose change bits miss `wake` (a selection of + /// one field cannot change when another field does). Order stays subscription order. + pub(crate) fn add_waking(self: &Rc, wake: u64, listener: Listener) -> Subscription { let id = self.next_id.get(); self.next_id.set(id + 1); - self.listeners.borrow_mut().push((id, listener)); + self.listeners.borrow_mut().push((id, wake, listener)); self.dirty.set(true); let weak: Weak = Rc::downgrade(self); Subscription::new(move || { @@ -41,25 +55,36 @@ impl Broadcast { fn remove(&self, id: u64) { let mut listeners = self.listeners.borrow_mut(); - if let Some(pos) = listeners.iter().position(|(i, _)| *i == id) { + if let Some(pos) = listeners.iter().position(|(i, _, _)| *i == id) { listeners.remove(pos); self.dirty.set(true); } } fn has(&self, id: u64) -> bool { - self.listeners.borrow().iter().any(|(i, _)| *i == id) + self.listeners.borrow().iter().any(|(i, _, _)| *i == id) } pub(crate) fn notify(&self) { + self.notify_changed(ALL); + } + + /// Notify the listeners whose wake bits meet `changed`. + pub(crate) fn notify_changed(&self, changed: u64) { if self.dirty.get() { - let fresh: Rc<[(u64, Listener)]> = self.listeners.borrow().iter().cloned().collect(); + let fresh: Rc<[(u64, u64, Listener)]> = + self.listeners.borrow().iter().cloned().collect(); *self.snapshot.borrow_mut() = fresh; + self.generation.set(self.generation.get() + 1); self.dirty.set(false); } let snap = self.snapshot.borrow().clone(); - for (id, listener) in snap.iter() { - let undisturbed = !self.dirty.get() && Rc::ptr_eq(&snap, &self.snapshot.borrow()); + let generation = self.generation.get(); + for (id, wake, listener) in snap.iter() { + if wake & changed == 0 { + continue; + } + let undisturbed = !self.dirty.get() && self.generation.get() == generation; if undisturbed || self.has(*id) { listener(); } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 81d6eea..800b324 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -543,9 +543,9 @@ impl ConfigBuilder { /// Data-reaction on a context field: its actions run (deferred) whenever the field /// changes, in any state, while the machine runs. - pub fn watch( + pub fn watch( &mut self, - field: Field, + field: Field, actions: impl IntoIterator>, ) -> &mut Self { self.inner.watch.push(Watch { diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs index d1a5823..88d4a5f 100644 --- a/crates/core/src/machine.rs +++ b/crates/core/src/machine.rs @@ -20,7 +20,7 @@ use crate::config::{ use crate::params::{ActionParams, GuardParams, View}; use crate::selection::Selection; use crate::timers::{Command, TimerId}; -use crate::traits::{Context, EventEnum, StateEnum, Types}; +use crate::traits::{Context, EventEnum, Field, StateEnum, Types}; /// Runaway guard for one flush. A real chain is a handful of items; thousands means a /// feedback loop (a watcher writing what it watches, actions sending in a cycle). @@ -66,12 +66,26 @@ pub(crate) struct Core { pub(crate) state_changed_at: u64, /// Effective changes made inside the current borrow; drained into notifications. pending_notifies: u32, + /// What those changes touched: field bits, plus `STATE_BIT` for a state change. + pending_changes: u64, /// Changes since the last `take_changes()`, for bindings. changed_fields: u64, changed_state: bool, } +/// A change bit no context field uses (fields take bits 0..64, all of them only with a +/// 64-field context, where a field selection may then also wake on a state change). +const STATE_BIT: u64 = 1 << 63; + impl Core { + /// The notifications owed since the last take, and what they changed. + fn take_owed(&mut self) -> (u32, u64) { + ( + std::mem::take(&mut self.pending_notifies), + std::mem::take(&mut self.pending_changes), + ) + } + /// Apply `patch`; return the mask of the fields that actually changed. pub(crate) fn patch(&mut self, patch: ::Patch) -> u64 { let mask = self.ctx.apply(patch); @@ -87,6 +101,7 @@ impl Core { self.stamp_fields(mask); self.changed_fields |= mask; self.pending_notifies += 1; + self.pending_changes |= mask; } /// Stamp the fields in `mask` as changed now. @@ -110,6 +125,7 @@ impl Core { self.state_changed_at = self.tick; self.changed_state = true; self.pending_notifies += 1; + self.pending_changes |= STATE_BIT; } } @@ -194,6 +210,7 @@ impl Machine { field_changed_at: Vec::new(), state_changed_at: 0, pending_notifies: 0, + pending_changes: 0, changed_fields: 0, changed_state: false, }; @@ -273,7 +290,9 @@ impl Machine { let reset = FlushReset(&inner.flushing, false); inner.process(Item::Event(event)); drop(reset); - inner.flush(); + if !inner.queue.borrow().is_empty() { + inner.flush(); + } } /// Shallow-merge `patch` into the context (bridges use this to refresh prop-seeded @@ -301,8 +320,22 @@ impl Machine { Selection::new(self.clone(), Rc::new(selector)) } + /// A value-deduped view of one context field (TS: `select.context(key)`). It wakes + /// only on writes to that field. + pub fn select_field(&self, field: Field) -> Selection { + Selection::waking( + self.clone(), + Rc::new(move |v: &View<'_, T>| field.get(v.context()).clone()), + field.bit(), + ) + } + pub fn select_state(&self) -> Selection { - self.select(|v| v.state()) + Selection::waking( + self.clone(), + Rc::new(|v: &View<'_, T>| v.state()), + STATE_BIT, + ) } pub fn select_computed(&self, key: ComputedKey) -> Selection> { @@ -476,12 +509,17 @@ impl Inner { match item { Item::Event(event) => { let kind = ::kind_index(event.kind()); - let state = self.core.borrow().state; + let (state, running) = { + let core = self.core.borrow(); + (core.state, core.running) + }; let Some(entry) = self.config.lookup(state, kind) else { return; }; + // Guards cannot write, so the state and the lifecycle read here hold + // until `apply`. if let Some(t) = self.resolve(entry, Some(&event)) { - self.apply(t, Some(&event)); + self.apply(t, Some(&event), state, running); } } Item::After { @@ -496,9 +534,10 @@ impl Inner { return; } } + // Same entry generation: the machine is still in `state`, running. let entry = &self.config.0.states[state.index()].after[index].1; if let Some(t) = self.resolve(entry, event.as_ref()) { - self.apply(t, event.as_ref()); + self.apply(t, event.as_ref(), state, true); } } Item::Watch { index } => { @@ -532,11 +571,7 @@ impl Inner { .find(|t| t.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) } - fn apply(&self, t: &Transition, event: Option<&T::Event>) { - let (cur, running) = { - let core = self.core.borrow(); - (core.state, core.running) - }; + fn apply(&self, t: &Transition, event: Option<&T::Event>, cur: T::State, running: bool) { let next = t.target.unwrap_or(cur); let leaving = next != cur; let states = &self.config.0.states; @@ -591,25 +626,32 @@ impl Inner { } fn call_action(&self, f: &crate::config::ActionFn, event: Option<&T::Event>) { - { + // The notifications owed are taken in the action's own borrow: one borrow, not two. + let (owed, changes) = { let mut core = self.core.borrow_mut(); - let mut params = ActionParams { + f(&mut ActionParams { core: &mut core, inner: self, event, - }; - f(&mut params); - } - self.drain_notifies(); + }); + core.take_owed() + }; + self.notify(owed, changes); } /// Deliver the notifications owed by the changes made in the last borrow — once per /// effective change, with no borrow held so observers can read and send. pub(crate) fn drain_notifies(&self) { - let n = std::mem::take(&mut self.core.borrow_mut().pending_notifies); + let (n, changes) = self.core.borrow_mut().take_owed(); + self.notify(n, changes); + } + + /// Notify the subscribers `n` times (once per effective change), with no borrow held; + /// `changes` is what those changes touched. + fn notify(&self, n: u32, changes: u64) { if let Some(bus) = self.bus.get() { for _ in 0..n { - bus.notify(); + bus.notify_changed(changes); } } } diff --git a/crates/core/src/selection.rs b/crates/core/src/selection.rs index b4cccd1..f319a85 100644 --- a/crates/core/src/selection.rs +++ b/crates/core/src/selection.rs @@ -1,7 +1,7 @@ use std::cell::RefCell; use std::rc::Rc; -use crate::broadcast::Subscription; +use crate::broadcast::{ALL, Subscription}; use crate::machine::Machine; use crate::params::View; use crate::traits::Types; @@ -12,6 +12,8 @@ type Selector = Rc) -> V>; pub struct Selection { machine: Machine, selector: Selector, + /// The change bits that can change the value; other notifications skip it. + wake: u64, } impl Clone for Selection { @@ -19,13 +21,23 @@ impl Clone for Selection { Self { machine: self.machine.clone(), selector: self.selector.clone(), + wake: self.wake, } } } impl Selection { pub(crate) fn new(machine: Machine, selector: Selector) -> Self { - Self { machine, selector } + Self::waking(machine, selector, ALL) + } + + /// A selection whose value can change only on the change bits in `wake`. + pub(crate) fn waking(machine: Machine, selector: Selector, wake: u64) -> Self { + Self { + machine, + selector, + wake, + } } /// The current selected value, evaluated on read. @@ -63,33 +75,43 @@ impl Selection { let prev: RefCell>> = RefCell::new(seed.map(Rc::new)); let selector = self.selector.clone(); let weak = Rc::downgrade(inner); - inner.bus().add(Rc::new(move || { - let Some(inner) = weak.upgrade() else { return }; - let next = { - let core = inner.core.borrow(); - selector(&View { - core: &core, - inner: &inner, - }) - }; - let seeded = match prev.borrow().as_deref() { - Some(old) if equals(old, &next) => return, - Some(_) => true, - None => false, - }; - let current = { - let mut slot = prev.borrow_mut(); - // Nobody else holds the last value (no listener of this wake still runs): - // overwrite it in place, so a steady stream of changes allocates nothing. - match slot.as_mut().and_then(Rc::get_mut) { - Some(old) => *old = next, - None => *slot = Some(Rc::new(next)), + // Unseeded, it must take its first wake whatever changed, or it would seed on the + // change it should report. + let wake = if prev.borrow().is_some() { + self.wake + } else { + ALL + }; + inner.bus().add_waking( + wake, + Rc::new(move || { + let Some(inner) = weak.upgrade() else { return }; + let next = { + let core = inner.core.borrow(); + selector(&View { + core: &core, + inner: &inner, + }) + }; + let seeded = match prev.borrow().as_deref() { + Some(old) if equals(old, &next) => return, + Some(_) => true, + None => false, + }; + let current = { + let mut slot = prev.borrow_mut(); + // Nobody else holds the last value (no listener of this wake still runs): + // overwrite it in place, so a steady stream of changes allocates nothing. + match slot.as_mut().and_then(Rc::get_mut) { + Some(old) => *old = next, + None => *slot = Some(Rc::new(next)), + } + slot.clone().expect("value just set") + }; + if seeded { + listener(¤t); } - slot.clone().expect("value just set") - }; - if seeded { - listener(¤t); - } - })) + }), + ) } } diff --git a/crates/core/src/traits.rs b/crates/core/src/traits.rs index 5b0e21f..278d0ea 100644 --- a/crates/core/src/traits.rs +++ b/crates/core/src/traits.rs @@ -1,6 +1,5 @@ use core::cell::Cell; use core::fmt::Debug; -use core::marker::PhantomData; /// A machine's finite set of states — a fieldless enum. Derive it with `#[derive(State)]`. pub trait StateEnum: Copy + Eq + Debug + 'static { @@ -89,19 +88,17 @@ impl SerializeFields for () { } } -/// A typed handle to one context field (generated as `Context::FIELD_NAME` consts). -pub struct Field { +/// A typed handle to one context field of type `V` (generated as `Context::FIELD_NAME` +/// consts): its index in the change mask, and how to read it. +pub struct Field { index: usize, - _context: PhantomData C>, + get: fn(&C) -> &V, } -impl Field { +impl Field { #[doc(hidden)] - pub const fn new(index: usize) -> Self { - Self { - index, - _context: PhantomData, - } + pub const fn new(index: usize, get: fn(&C) -> &V) -> Self { + Self { index, get } } pub const fn index(self) -> usize { self.index @@ -109,15 +106,19 @@ impl Field { pub const fn bit(self) -> u64 { 1 << self.index } + /// The field's value in `context`. + pub fn get(self, context: &C) -> &V { + (self.get)(context) + } } -impl Clone for Field { +impl Clone for Field { fn clone(&self) -> Self { *self } } -impl Copy for Field {} -impl Debug for Field { +impl Copy for Field {} +impl Debug for Field { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "Field({})", self.index) } diff --git a/crates/core/tests/subscribe.rs b/crates/core/tests/subscribe.rs index 0bf1041..129c64e 100644 --- a/crates/core/tests/subscribe.rs +++ b/crates/core/tests/subscribe.rs @@ -6,10 +6,8 @@ //! `select` is a method. //! - "without equals, a fresh-object selector fires every change (Object.is)": identity //! semantics. Rust selections compare with `PartialEq`, which the value-dedup test pins. -//! - "select.context(key) selects one field with the exact value type" and "select(fn) -//! function form still works alongside the scope methods": JS facade shapes. Rust's -//! `Field` handles carry no value type, so a one-field selection is -//! `select(|v| v.context().x)`, the selection the tests below pin. +//! - "select(fn) function form still works alongside the scope methods": a JS facade +//! shape; in Rust `select` and `select_field` are plain methods. //! - ".value reads the current selected value": the first of two such TS tests; the //! second ("on demand") covers it. //! - "does NOT fire on subscribe; fires on a context change", "multiple subscribers all @@ -239,3 +237,55 @@ fn a_selection_listener_may_patch_the_machine_it_observes() { m.send(PlaneEvent::MoveX); assert_eq!(seen.entries(), [1, 5]); } + +#[test] +fn select_field_selects_one_field_with_its_type() { + let m = plane(); + let x = m.select_field(Pos::X); + let xv: i32 = x.value(); + assert_eq!(xv, 0); + let seen: Log = Log::default(); + let _ = x.subscribe({ + let seen = seen.clone(); + move |x| seen.push(*x) + }); + m.send(PlaneEvent::BumpOther); // changed `other`, not `x`: silent + assert!(seen.entries().is_empty()); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [1]); +} + +// Rust-only: a selection made inside an action cannot read the machine yet, so it seeds +// on its first wake. A field selection must still report the next change of its field, +// even when that first wake came from another field. +#[test] +fn a_field_selection_made_mid_action_reports_the_next_change() { + type Slot = std::rc::Rc>>>; + let handle: Slot = Slot::default(); + let seen: Log = Log::default(); + let m = build( + Config::::builder(PlaneState::Idle, Pos::default()).state(PlaneState::Idle, |s| { + let (handle, seen) = (handle.clone(), seen.clone()); + s.on(PlaneEventKind::MoveX, |t| { + t.act(|p| Pos::patch().x(p.context().x + 1)) + }) + .on(PlaneEventKind::BumpOther, move |t| { + t.run(move |p| { + if p.context().other == 0 { + // The core is borrowed by this action: the selection seeds later. + let m = handle.borrow().clone().expect("machine"); + std::mem::forget(m.select_field(Pos::X).subscribe({ + let seen = seen.clone(); + move |x| seen.push(*x) + })); + } + p.set_context(Pos::patch().other(p.context().other + 1)); + }) + }) + }), + ); + *handle.borrow_mut() = Some(m.clone()); + m.send(PlaneEvent::BumpOther); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [1]); +} diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 6e2ea93..3d34b79 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -511,7 +511,8 @@ fn expand_context(input: DeriveInput) -> syn::Result { #[allow(dead_code)] impl #name { - #(pub const #consts: ::dunky_core::Field<#name> = ::dunky_core::Field::new(#indices);)* + #(pub const #consts: ::dunky_core::Field<#name, #tys> = + ::dunky_core::Field::new(#indices, |c: &#name| &c.#idents);)* /// Start a patch: `Self::patch().field(value)`. pub fn patch() -> #patch { diff --git a/crates/wasm/README.md b/crates/wasm/README.md index c669dbe..757e82e 100644 --- a/crates/wasm/README.md +++ b/crates/wasm/README.md @@ -50,3 +50,11 @@ The crate using the macro also depends on `wasm-bindgen` (the version this crate and its event type derives `#[event(deserialize)]`. What the protocol guarantees: [`SPEC.md`](./SPEC.md). + +## Why the TS engine does not run on Rust + +We tried it: the TS engine as a thin layer over the Rust engine, compiled to wasm. It was +not faster. Every event crosses the JS↔wasm bridge, and user code (guards, actions, +effects) stays in JS, so it crosses back. After batching the calls, one event reached +~9.9 M/sec, still ~15% below the pure TS engine (11.6 M/sec), and the package grew by +~75 kB gzip. So each engine runs on its own, and wasm is for machines written in Rust. diff --git a/packages/wasm/README.md b/packages/wasm/README.md index 93fa695..eadb2f9 100644 --- a/packages/wasm/README.md +++ b/packages/wasm/README.md @@ -39,3 +39,11 @@ const palette = fromWasm(new PaletteMachine(commands)) In JS a TS machine is a little faster (its context already lives in JS); a Rust machine is for reuse: the same machine runs in Rust programs and on React Native. The protocol: [`crates/wasm/SPEC.md`](../../crates/wasm/SPEC.md). + +## Why the TS engine does not run on Rust + +We tried it: the TS engine as a thin layer over the Rust engine, compiled to wasm. It was +not faster. Every event crosses the JS↔wasm bridge, and user code (guards, actions, +effects) stays in JS, so it crosses back. After batching the calls, one event reached +~9.9 M/sec, still ~15% below the pure TS engine (11.6 M/sec), and the package grew by +~75 kB gzip. So each engine runs on its own, and wasm is for machines written in Rust. diff --git a/website/src/content/docs/rust/machines.mdx b/website/src/content/docs/rust/machines.mdx index c5c2f57..cfcda3b 100644 --- a/website/src/content/docs/rust/machines.mdx +++ b/website/src/content/docs/rust/machines.mdx @@ -83,6 +83,8 @@ assert!(dialog.has_tag("visible")); assert_eq!(dialog.context().open_count, 1); let _sub = dialog.subscribe(|| println!("changed")); +// One field, typed (TS: select.context('openCount')): wakes only when it changes. +let _count = dialog.select_field(DialogCtx::OPEN_COUNT).subscribe(|n| println!("{n}")); ``` ## Timers are commands diff --git a/website/src/content/docs/rust/typescript.mdx b/website/src/content/docs/rust/typescript.mdx index 7b7e37b..2be99a8 100644 --- a/website/src/content/docs/rust/typescript.mdx +++ b/website/src/content/docs/rust/typescript.mdx @@ -92,3 +92,11 @@ In JS, a machine written in TypeScript is a little faster: its context already l JS, while a Rust machine refreshes a JS mirror of its context on each change. Write a machine in Rust to **reuse** it: the same machine runs in Rust programs and on React Native. + +## Why the TS engine does not run on Rust + +We tried it: the TS engine as a thin layer over the Rust engine, through wasm. It was not +faster. Every event crosses the JS↔wasm bridge, and your guards, actions and effects stay +in JS, so the calls cross back. After batching them, one event reached ~9.9 M/sec, still +~15% below the pure TS engine (11.6 M/sec), with ~75 kB gzip more to download. So each +engine runs on its own, and wasm is for machines written in Rust. From ef3da071aa67c788a976b2b77a001c7e6c39967b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Thu, 8 Oct 2026 20:31:41 +0200 Subject: [PATCH 28/28] docs: why two engines, not Rust everywhere 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 --- crates/wasm/README.md | 15 ++++++++------- packages/wasm/README.md | 15 ++++++++------- website/src/content/docs/rust/typescript.mdx | 15 ++++++++------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/crates/wasm/README.md b/crates/wasm/README.md index 757e82e..7a2b752 100644 --- a/crates/wasm/README.md +++ b/crates/wasm/README.md @@ -51,10 +51,11 @@ and its event type derives `#[event(deserialize)]`. What the protocol guarantees: [`SPEC.md`](./SPEC.md). -## Why the TS engine does not run on Rust - -We tried it: the TS engine as a thin layer over the Rust engine, compiled to wasm. It was -not faster. Every event crosses the JS↔wasm bridge, and user code (guards, actions, -effects) stays in JS, so it crosses back. After batching the calls, one event reached -~9.9 M/sec, still ~15% below the pure TS engine (11.6 M/sec), and the package grew by -~75 kB gzip. So each engine runs on its own, and wasm is for machines written in Rust. +## Why two engines, not Rust everywhere + +We tried one engine: 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 still paid the JS↔wasm bridge, so it gave up a +bit of speed. So we maintain both engines against one spec: each one runs natively, and +wasm is for machines written in Rust. diff --git a/packages/wasm/README.md b/packages/wasm/README.md index eadb2f9..34d1ad1 100644 --- a/packages/wasm/README.md +++ b/packages/wasm/README.md @@ -40,10 +40,11 @@ In JS a TS machine is a little faster (its context already lives in JS); a Rust is for reuse: the same machine runs in Rust programs and on React Native. The protocol: [`crates/wasm/SPEC.md`](../../crates/wasm/SPEC.md). -## Why the TS engine does not run on Rust - -We tried it: the TS engine as a thin layer over the Rust engine, compiled to wasm. It was -not faster. Every event crosses the JS↔wasm bridge, and user code (guards, actions, -effects) stays in JS, so it crosses back. After batching the calls, one event reached -~9.9 M/sec, still ~15% below the pure TS engine (11.6 M/sec), and the package grew by -~75 kB gzip. So each engine runs on its own, and wasm is for machines written in Rust. +## Why two engines, not Rust everywhere + +We tried one engine: 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 still paid the JS↔wasm bridge, so it gave up a +bit of speed. So we maintain both engines against one spec: each one runs natively, and +wasm is for machines written in Rust. diff --git a/website/src/content/docs/rust/typescript.mdx b/website/src/content/docs/rust/typescript.mdx index 2be99a8..6834880 100644 --- a/website/src/content/docs/rust/typescript.mdx +++ b/website/src/content/docs/rust/typescript.mdx @@ -93,10 +93,11 @@ JS, while a Rust machine refreshes a JS mirror of its context on each change. Wr machine in Rust to **reuse** it: the same machine runs in Rust programs and on React Native. -## Why the TS engine does not run on Rust - -We tried it: the TS engine as a thin layer over the Rust engine, through wasm. It was not -faster. Every event crosses the JS↔wasm bridge, and your guards, actions and effects stay -in JS, so the calls cross back. After batching them, one event reached ~9.9 M/sec, still -~15% below the pure TS engine (11.6 M/sec), with ~75 kB gzip more to download. So each -engine runs on its own, and wasm is for machines written in Rust. +## Why two engines, not Rust everywhere + +We tried one engine: 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 still paid the JS↔wasm bridge, so it gave up a +bit of speed. So we maintain both engines against one spec: each one runs natively, and +wasm is for machines written in Rust.