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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/solid-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@dunky.dev/state-machine-solid': minor
---

Add `@dunky.dev/state-machine-solid` — the Solid bindings target.

A first-class Solid bridge (not a React re-export): `useMachine` mirrors the
connector's snapshot into a Solid `createStore` (via `reconcile`) so reading a
field in JSX is fine-grained, runs the lifecycle through `onMount`/`onCleanup`,
keeps props fresh with a tracked `setProps` effect, and runs each
`ComponentEffect` as its own dep-tracked `createEffect`. `useSelector` returns a
Solid accessor. `normalize` maps the agnostic bindings to Solid DOM props
(`onInput`, `onDblClick`, `tabindex`) and `mergeProps` applies Solid's `class`
concat + single-object `style` merge. The same `connect` and machine config run
unchanged across React, Solid, React Native, and OpenTUI.
2 changes: 2 additions & 0 deletions benchmark/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// don't typecheck), so this is the only thing that catches type errors here.
"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"],
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"website:dev": "pnpm -C website dev",
"website:prod": "pnpm -C website build",
"build": "tsdown",
"typecheck": "tsc -b tsconfig.all.json",
"typecheck": "tsc -b tsconfig/all.json",
"lint": "oxlint .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
Expand All @@ -30,8 +30,10 @@
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"publint": "^0.3.21",
"solid-js": "^1.9.13",
"tsdown": "^0.22.2",
"typescript": "^6.0.3",
"vite-plugin-solid": "^2.11.12",
"vitest": "^4.1.7"
},
"packageManager": "pnpm@10.20.0"
Expand Down
1 change: 1 addition & 0 deletions packages/solid/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @dunky.dev/state-machine-solid
21 changes: 21 additions & 0 deletions packages/solid/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
205 changes: 205 additions & 0 deletions packages/solid/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# `@dunky.dev/state-machine-solid`

The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). The
core engine is renderer-agnostic; this package is the thin Solid edge that drives
it: it builds the machine + connector, runs the Solid lifecycle, mirrors the
connector's snapshot into a fine-grained store, translates the agnostic
[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM
props, and owns the per-component substrate effects.

This is a **first-class Solid target**, not a re-export of the React bridge.
React's adapters re-export onto React Native and OpenTUI because those share a
React reconciler; Solid has its own fine-grained reactivity, so the lifecycle is
implemented with Solid primitives — `createStore` + `reconcile`, `createEffect`,
`onMount`/`onCleanup` — and there is no `useSyncExternalStore`. The behavior
still lives in the core machine and the component's `connect`; this layer only
adapts them to Solid. Four exports: `useMachine`, `useSelector`, `normalize`,
`mergeProps`, plus the `ComponentEffect` types.

---

## `useMachine` — the one bridge hook

Every component's generated `useXxxApi` calls this with the agnostic pieces:

```ts
const { api, machine } = useMachine(
tooltipMachineConfig, // (props) => config — config factory, props seed it ONCE
connectTooltip, // pure connect(): snapshot → view api
tooltipEffects, // the component's substrate effects (ComponentEffect[])
props, // the reactive Solid props
)
```

It:

- **builds once** — `machine(createConfig(props))` + `connector(service, connect,
{ ...props })`. A Solid component body runs a single time, so a plain build IS
"build once" (no `useMemo` equivalent). The first props seed context and the
initial state; later prop changes flow through `setProps`, never a rebuild.
> The connector is seeded with a **plain snapshot** (`{ ...props }`), never the
> live Solid props proxy. The connector value-dedups in `setProps`; if it held
> the proxy it would later compare the proxy against a fresh spread of that same
> proxy — whose getters have already updated — find them equal, and never wake.
- **is fine-grained** — the connector's snapshot is mirrored into a
`createStore` via `reconcile` on every connector wake. Reading `api.isOpen` in
JSX subscribes to exactly that leaf, so an unrelated field changing won't touch
it. `api` is the store proxy — **don't destructure it** (`const { isOpen } =
api` snapshots the value and drops reactivity); read its fields where you use
them.
- **keeps props fresh** via a tracked effect — `createEffect(() =>
connection.setProps({ ...props }))`. Solid auto-tracks every prop read in the
spread, so it re-runs whenever a consumed prop changes, with no manual dep
list. `setProps` value-dedups.
- **runs the lifecycle** — `service.start()` in `onMount`, `service.stop()` in
`onCleanup`. The connector wired its
[reactions](../core/README.md#reactions--firing-prop-callbacks-without-the-machine-knowing)
to the machine's own `start`/`stop`, so prop-callbacks follow automatically.
- **runs the component's substrate effects** — one `createEffect` per
`ComponentEffect` entry, each reading its named prop deps so it re-subscribes
only when one of them changes (see below).

Returns `{ api, machine }`: `api` is the reactive store to spread onto elements;
`machine` is the running service (also handed to `useSelector`).

---

## `ComponentEffect` — substrate transport, without the boilerplate

Some behavior can't live in the agnostic machine because it needs the **platform
itself** — a DOM `keydown` listener for Escape, a `ResizeObserver` — and the
**props** the machine never sees (`closeOnEscape`). That's the component's
Solid-side _effect_.

Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`) — the
**same shape as every other target**, so a component's effects are authored once
and run unchanged on React and Solid:

```ts
import type { ComponentEffect } from '@dunky.dev/state-machine-solid'

type TooltipEffect = ComponentEffect<TooltipMachine, TooltipMachineProps>

/** Escape-to-close (gated by closeOnEscape). */
const trackEscape: TooltipEffect = [
(machine, props) => {
if (!props.closeOnEscape) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') machine.send({ type: 'escape' })
}
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
},
['closeOnEscape'], // ← re-run only when this prop changes
]

export const tooltipEffects = [trackEscape]
```

`useMachine` runs the list — **one `createEffect` per entry**. Each effect READS
its declared prop deps, so Solid's auto-tracking re-runs it (cleanup → setup)
only when one of those props actually changes, never on unrelated changes. The
deps are prop NAMES — typed `(keyof Props)[]`, so a typo is a compile error.
Reading the deps explicitly (rather than letting the effect body's own reads
decide) keeps the dependency set driven by the authored `deps` and identical to
every other target.

> The agnostic _decision_ lives in the core component's resolver; only the
> _transport_ (the DOM listener) is here. The machine just receives a plain event.

---

## `useSelector` — fine-grained leaf subscription

Returns a Solid **accessor** that updates only when one slice of the machine
changes:

```ts
const open = useSelector(machine, () => machine.matches('open'))
const isHL = useSelector(machine, () => machine.context.highlightedValue === value)
// read it in JSX: <div data-open={open()} />
```

Backed by a `createSignal` driven by the machine's `select` — a value-deduped
Selection. `Object.is` by default; **an object/array selection MUST pass a custom
`isEqual`** so a re-derived equal value doesn't push a change:

```ts
const pos = useSelector(
machine,
() => ({ x: machine.context.x, y: machine.context.y }),
(a, b) => a.x === b.x && a.y === b.y,
)
```

`api` from `useMachine` is already fine-grained, so reach for `useSelector` when
a leaf wants to track one slice of a machine it doesn't otherwise own — e.g.
thousands of rows backed by one machine, each waking only for its own value
(`O(readers)`).

---

## `normalize` — agnostic bindings → DOM props

`connect` returns substrate-agnostic
[bindings](../core/README.md#connector--the-view-boundary) (`onPress`, `role`).
`normalize` translates them to DOM/ARIA props as Solid's JSX expects them:

```ts
const domProps = normalize(api.triggerProps) // { onClick, 'aria-expanded', role, tabindex, ... }
```

Same vocabulary as the React DOM normalizer, with Solid's JSX conventions:

| Agnostic binding | Solid DOM prop |
| ---------------- | ------------------------------------- |
| `onPress` | `onClick` |
| `onValueChange` | `onInput` (wrapped → `ChangePayload`) |
| `onDoublePress` | `onDblClick` |
| `focusable` | `tabindex` (`true → 0`, `false → -1`) |

Pointer/keyboard/focus handlers and the full ARIA attribute set map exactly as in
the [React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props).
`undefined` values are dropped; any key not in the map (`class`, `data-*`) passes
through unchanged. `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` are wrapped
so the consumer receives the agnostic payload built from the native DOM event.

---

## `mergeProps` — combine consumer props with the component's props

When a consumer spreads their own props onto the same element the component
controls, `mergeProps(consumer, library)` merges them the Radix/Ark way, Solid
flavor:

```ts
const finalProps = mergeProps(consumerProps, normalize(api.triggerProps))
```

- **Event handlers are chained, consumer-first** — both run, but if the
consumer's handler marks the event `defaultPrevented`, the library handler is
skipped (a clean veto).
- **`class` is concatenated** with a single space and trimmed (Solid uses
`class`, not React's `className`).
- **`style` is merged into ONE object**, library winning on conflicting keys.
Solid's `style` is a plain object, not React's array form — so styles merge
rather than wrap.
- **Everything else: library wins** (`id`, `role`, `aria-*`).

> This is **not** Solid's own `mergeProps` from `solid-js` (which merges reactive
> prop objects). It merges the consumer's props with the component's normalized
> bindings.

---

## API

| Export | What it is |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `useMachine(config, connect, effects, props)` | the bridge hook — build once + lifecycle + run effects + fine-grained store; returns `{ api, machine }` |
| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice; returns a Solid accessor (`O(readers)`) |
| `normalize(bindings)` | agnostic bindings → Solid DOM/ARIA props |
| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `class` concat; `style` object merge) |
| `ComponentEffect<M, P>` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps |
| `ComponentEffects<M, P>` | `ComponentEffect<M, P>[]` — a component's effect list |
| `Bindings` | `Record<string, unknown>` — the loose shape `normalize` accepts |
47 changes: 47 additions & 0 deletions packages/solid/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@dunky.dev/state-machine-solid",
"version": "0.2.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dunky-dev/state-machine.git",
"directory": "packages/solid"
},
"files": [
"dist"
],
"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:^",
"@dunky.dev/state-machine-utils": "workspace:^"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
"jsdom": "^29.1.1",
"solid-js": "^1.9.13"
},
"peerDependencies": {
"solid-js": "^1.6.0"
}
}
4 changes: 4 additions & 0 deletions packages/solid/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine'
export { useSelector } from './use-selector'
export { normalize, type Bindings } from './normalize'
export { mergeProps } from './merge-props'
35 changes: 35 additions & 0 deletions packages/solid/src/merge-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'

type AnyProps = Record<string, unknown>

/**
* Merge consumer props with the component's normalized props, Solid-style.
*
* Layers Solid's DOM conventions on the substrate-agnostic mergeProps (handler
* compose with the `defaultPrevented` veto; everything else library-wins):
*
* - `class` is concatenated with a space (Solid uses `class`, not React's
* `className`).
* - `style` is merged into ONE object, library winning on conflicting keys.
* Solid's `style` prop is a plain object (or string), NOT React's array form —
* so styles merge rather than wrap. (String styles fall through to
* library-wins; mixing a string and an object on the same element is a consumer
* error Solid itself wouldn't merge either.)
*/
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
const merged = baseMergeProps(consumer, library)
if (!consumer) return merged

if (typeof consumer.class === 'string' && typeof library.class === 'string') {
merged.class = `${consumer.class} ${library.class}`.trim()
}
if (isStyleObject(consumer.style) && isStyleObject(library.style)) {
merged.style = { ...consumer.style, ...library.style }
}

return merged
}

function isStyleObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
Loading
Loading