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
5 changes: 5 additions & 0 deletions .changeset/svelte-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@dunky.dev/state-machine-svelte': minor
---

Add Svelte 5 bindings: `@dunky.dev/state-machine-svelte`. A thin, runes-based edge layer mirroring the React package — `useMachine` (build-once bridge + lifecycle + prop-scoped substrate effects, returning a reactive `{ api, machine }`), `useSelector` (fine-grained leaf subscription returning `{ current }`), and `normalize`/`mergeProps` for Svelte's DOM idiom (lowercase `on*` event props, `class`/`style` string merge). The package ships its `src` uncompiled so the consumer's Svelte compiler processes its `.svelte.ts` runes modules.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@testing-library/svelte": "^5.4.2",
"@types/node": "^22.10.2",
"husky": "^9.1.7",
"knip": "^6.15.0",
Expand Down
21 changes: 21 additions & 0 deletions packages/svelte/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.
217 changes: 217 additions & 0 deletions packages/svelte/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# `@dunky.dev/state-machine-svelte`

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

Everything here is deliberately small — the behavior lives in the core machine
and the component's `connect`; this layer only adapts them to Svelte. There are
four exports: one bridge (`useMachine`, which also runs the component's substrate
effects), one leaf-subscription helper (`useSelector`), and two prop helpers
(`normalize`, `mergeProps`) — plus the `ComponentEffect` types.

> **Svelte 5 only.** `useMachine` and `useSelector` use runes (`$state`,
> `$effect`), so they ship as `.svelte.ts` modules and are compiled by your
> Svelte build (Vite plugin / SvelteKit), exactly like a component. The package
> ships its `src` uncompiled for that reason.

---

## `useMachine` — the one bridge

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

```svelte
<script lang="ts">
const view = useMachine(
tooltipMachineConfig, // (props) => config — config factory, props seed it ONCE
connectTooltip, // pure connect(): snapshot → view api
tooltipEffects, // the component's substrate effects (ComponentEffect[])
() => props, // a GETTER for the resolved props (not a value)
)
</script>

<button {...normalize(view.api.triggerProps)}>…</button>
```

It:

- **builds once** — `machine(createConfig(props))` + `connector(service, connect, props)`.
The first props read seeds context and the initial state; recreating would lose
state, so later prop changes flow through `setProps`, not a rebuild.
- **keeps props fresh** via an `$effect` calling `connection.setProps(getProps())`.
`getProps()` reads the component's reactive props, so it re-runs when they
change; `setProps` value-dedups, so an unchanged read doesn't churn.
- **runs the lifecycle**: `service.start()` on setup, `service.stop()` on destroy,
wired in one `$effect` whose cleanup Svelte calls automatically. 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 with no teardown
threading here.
- **runs the component's substrate effects** — one `$effect` per `ComponentEffect`
entry, each reading only its named prop deps (see below).
- **exposes the snapshot** through a `$state`-backed `view.api` getter, seeded with
the connector's initial snapshot and reassigned on each connector notify. The
connector memoizes, so the identity changes only on a real change — reading
`view.api` in markup updates only then.

Returns `{ api, machine }` (both getters): `api` is the `connect()` output to
spread onto elements; `machine` is the running service (also handed to
`useSelector`).

### Why a props getter

React hands `useMachine` a fresh `props` value each render. Svelte props are
reactive bindings, so the bridge instead takes `() => props` and reads it inside
its effects — that's how `setProps` and the substrate effects see current values
without a per-render call. Pass `() => props` (or `() => ({ ...resolved })` after
applying defaults).

---

## `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
Svelte-side _effect_.

Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`), the
**same shape as the React binding** — only how `useMachine` runs it differs:

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

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 `$effect` per entry**. Each effect reads only
its named props, so runes wake it _only when one of those values actually
changes_ (the precise-dependency behavior React got from a manual dep array, here
from automatic tracking) — never for an unrelated machine change. Returning the
setup's teardown lets Svelte clean it up on re-run / destroy.

> Unlike React there's no rules-of-hooks constraint, so the list need not be a
> module constant — but keeping it one (`export const xEffects = [...]`) stays the
> tidy convention.

> The agnostic _decision_ (gate + veto) belongs in the core component's resolver;
> only the _transport_ (the DOM listener) is here. Same split as everywhere:
> agnostic policy in core, platform wiring at the edge.

---

## `useSelector` — fine-grained leaf subscription

For a leaf that should update only when **one slice** of the machine changes (the
`O(readers)` path that matters at scale — thousands of items, each waking only for
its own value):

```ts
const open = useSelector(machine, () => machine.matches('open'))
// in markup: {#if open.current} … {/if}
```

It returns `{ current }` — a single reactive getter (a bare value can't carry its
reactivity across the `return`). The selector reads the machine directly and the
value updates only when the selected value changes — `Object.is` by default. **A
selector that returns a fresh object/array each call should pass a custom
`isEqual`** so an equal-but-new value isn't seen as 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,
)
```

Internally it wraps the selector in one `Selection` and subscribes in an
`$effect`, writing a `$state` cell that `current` reads. The Selection's
value-dedup gates the update; there's no getSnapshot-identity hazard to guard
against (unlike React) because `$state` only notifies on reassignment.

**`useMachine` vs. `useSelector`.** `useMachine` drives the whole component off
the connector's snapshot (memoized, so it updates only on a real change).
`useSelector` is for _within_ that tree — a child that wants to track just one
field. Reach for it when a subtree is large enough that whole-snapshot updates are
wasteful.

---

## `normalize` — agnostic bindings → DOM props

`connect` returns substrate-agnostic [bindings](../core/README.md#connector--the-view-boundary)
(`onPress`, `describedBy`, `role`). `normalize` translates them to real DOM/ARIA
props in Svelte's idiom — lowercase `on*` events, `tabindex`, `aria-*`:

```ts
const domProps = normalize(view.api.triggerProps) // { onclick, 'aria-describedby', role, … }
```

The mapping mirrors the React DOM normalizer, with two Svelte differences: event
props are the **lowercase DOM names** (`onclick`, `onkeydown`) rather than
camelCase synthetic-event props, and `focusable` → `tabindex` (lowercase). The
`aria-*` names are identical — ARIA is part of the DOM, not the framework. A few
handlers whose agnostic payload differs from the raw event
(`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer
receives the agnostic payload. `undefined` values are dropped, and any key not in
the map passes through unchanged.

---

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

When a consumer spreads their own props onto the same element the component
controls, the two prop sets have to merge sensibly. `mergeProps(consumer, library)`
does it the Radix/Ark way:

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

- **Event handlers are chained, consumer-first** — both run, the consumer's
before the library's — **but if the consumer's handler marks the event
`defaultPrevented`, the library handler is skipped.** Detection is on Svelte's
lowercase `on*` props.
- **`style` is concatenated** as a string (Svelte styles are strings, not React's
array form), joined with `; ` and trimmed. String + string only; else library
wins.
- **`class` is concatenated** with a single space and trimmed (the Svelte
attribute name, React's `className`). String + string only; else library wins.
- **Everything else: library wins** — the component owns its semantics (`id`,
`role`, `aria-*`).

If the consumer passes no props, the library props are returned as-is.

---

## API

| Export | What it is |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `useMachine(config, connect, effects, getProps)` | the bridge — build once + lifecycle + run the component effects + reactive snapshot; returns `{ api, machine }` (getters) |
| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice (`O(readers)`); returns `{ current }` |
| `normalize(bindings)` | agnostic bindings → DOM/ARIA props (lowercase `on*`, `tabindex`, `aria-*`) |
| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `style`/`class` concatenated; else library wins) |
| `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 |
34 changes: 34 additions & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@dunky.dev/state-machine-svelte",
"version": "0.2.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dunky-dev/state-machine.git",
"directory": "packages/svelte"
},
"files": [
"src"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"default": "./src/index.ts"
}
},
"dependencies": {
"@dunky.dev/state-machine": "workspace:^"
},
"devDependencies": {
"svelte": "^5.43.2"
},
"peerDependencies": {
"svelte": "^5"
}
}
4 changes: 4 additions & 0 deletions packages/svelte/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.svelte'
export { useSelector } from './use-selector.svelte'
export { normalize, type Bindings } from './normalize'
export { mergeProps } from './merge-props'
71 changes: 71 additions & 0 deletions packages/svelte/src/merge-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
type AnyProps = Record<string, unknown>
type AnyHandler = (...args: unknown[]) => unknown

// A Svelte DOM event prop: lowercase `on` + an event name (`onclick`,
// `onkeydown`, `onpointerenter`). This is where the Svelte merge diverges from
// the shared `baseMergeProps`, whose detector keys off React's camelCase form
// (`on` + an UPPERCASE letter). After `normalize`, the library props are all
// lowercase, so we chain on the lowercase shape instead.
const isEventHandlerKey = (key: string): boolean =>
key.length > 2 && key.startsWith('on') && key[2] !== key[2]!.toUpperCase()

const isFn = (v: unknown): v is AnyHandler => typeof v === 'function'

function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler {
return (...args) => {
consumer(...args)
// Respect consumer's defaultPrevented — if the first arg looks like an event
// whose default was prevented, the library handler is skipped. Matches the
// Radix/Ark convention the React/base mergers use.
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (event && typeof event === 'object' && event.defaultPrevented) return
return library(...args)
}
}

/**
* Merge a consumer's props with the component's (library) props for the same
* element — the Svelte counterpart of the React `mergeProps`.
*
* - **Event handlers are chained, consumer-first**, with the same
* `defaultPrevented` veto: if the consumer's handler prevents the event, the
* library's is skipped. Detection is on Svelte's lowercase `on*` props.
* - **`class` is concatenated** with a single space and trimmed (`'a b'` + `'c'`
* → `'a b c'`), the Svelte attribute name (React's `className`). String + string
* only; otherwise library wins.
* - **`style` is concatenated** as a string (Svelte styles are strings, not the
* React array form), joined with `; ` and trimmed. String + string only;
* otherwise library wins.
* - **Everything else: library wins** — the component owns its semantics
* (`id`, `role`, `aria-*`).
*
* If the consumer passes no props, the library props are returned as-is.
*/
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
if (!consumer) return library
const out: AnyProps = { ...consumer }

for (const [key, libValue] of Object.entries(library)) {
const consumerValue = consumer[key]

if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) {
out[key] = compose(consumerValue, libValue)
continue
}

if (key === 'class' && typeof consumerValue === 'string' && typeof libValue === 'string') {
out.class = `${consumerValue} ${libValue}`.trim()
continue
}

if (key === 'style' && typeof consumerValue === 'string' && typeof libValue === 'string') {
out.style = `${consumerValue.replace(/;\s*$/, '')}; ${libValue}`.trim()
continue
}

// Default: library wins.
out[key] = libValue
}

return out
}
Loading
Loading