From a1b723d200e0baf33f9520a523f0b262c2f2bfef Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 23 Jun 2026 22:22:44 +0200 Subject: [PATCH 01/32] feat(svelte): add Svelte 5 bindings package Add @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 }. Takes props as a getter (() => props) since Svelte props are reactive bindings. - useSelector: fine-grained leaf subscription returning { current }. - normalize/mergeProps: Svelte DOM idiom (lowercase on* event props, class/style string merge). The package ships its src uncompiled (svelte export condition) so the consumer's Svelte compiler processes the .svelte.ts runes modules; it's excluded from the tsdown bundle for that reason. Vitest gains a scoped svelte project (svelte plugin + svelteTesting + jsdom) so runes tests run without disturbing the other packages. Adds docs (libs/svelte.mdx + sidebar entry) and a changeset. Co-authored-by: Claude --- .changeset/svelte-integration.md | 5 + package.json | 2 + packages/svelte/LICENSE | 21 ++ packages/svelte/README.md | 217 ++++++++++++ packages/svelte/package.json | 34 ++ packages/svelte/src/index.ts | 4 + packages/svelte/src/merge-props.ts | 71 ++++ packages/svelte/src/normalize.ts | 180 ++++++++++ packages/svelte/src/use-machine.svelte.ts | 149 +++++++++ packages/svelte/src/use-selector.svelte.ts | 70 ++++ packages/svelte/tests/fixtures/counters.ts | 26 ++ .../tests/fixtures/selector-harness.svelte | 36 ++ .../tests/fixtures/toggle-harness.svelte | 61 ++++ packages/svelte/tests/fixtures/toggle.ts | 50 +++ packages/svelte/tests/merge-props.test.ts | 76 +++++ packages/svelte/tests/normalize.test.ts | 308 ++++++++++++++++++ packages/svelte/tests/use-machine.test.ts | 105 ++++++ packages/svelte/tests/use-selector.test.ts | 65 ++++ pnpm-lock.yaml | 162 ++++++++- tsconfig.json | 1 + tsdown.config.ts | 6 + vitest.config.ts | 36 +- website/astro.config.ts | 1 + website/src/content/docs/libs/svelte.mdx | 188 +++++++++++ 24 files changed, 1865 insertions(+), 9 deletions(-) create mode 100644 .changeset/svelte-integration.md create mode 100644 packages/svelte/LICENSE create mode 100644 packages/svelte/README.md create mode 100644 packages/svelte/package.json create mode 100644 packages/svelte/src/index.ts create mode 100644 packages/svelte/src/merge-props.ts create mode 100644 packages/svelte/src/normalize.ts create mode 100644 packages/svelte/src/use-machine.svelte.ts create mode 100644 packages/svelte/src/use-selector.svelte.ts create mode 100644 packages/svelte/tests/fixtures/counters.ts create mode 100644 packages/svelte/tests/fixtures/selector-harness.svelte create mode 100644 packages/svelte/tests/fixtures/toggle-harness.svelte create mode 100644 packages/svelte/tests/fixtures/toggle.ts create mode 100644 packages/svelte/tests/merge-props.test.ts create mode 100644 packages/svelte/tests/normalize.test.ts create mode 100644 packages/svelte/tests/use-machine.test.ts create mode 100644 packages/svelte/tests/use-selector.test.ts create mode 100644 website/src/content/docs/libs/svelte.mdx diff --git a/.changeset/svelte-integration.md b/.changeset/svelte-integration.md new file mode 100644 index 0000000..f705eec --- /dev/null +++ b/.changeset/svelte-integration.md @@ -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. diff --git a/package.json b/package.json index 6fe0557..cafa3c9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/svelte/LICENSE b/packages/svelte/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/svelte/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/svelte/README.md b/packages/svelte/README.md new file mode 100644 index 0000000..aba99a8 --- /dev/null +++ b/packages/svelte/README.md @@ -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 + + + +``` + +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 + +/** 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` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps | +| `ComponentEffects` | `ComponentEffect[]` — a component's effect list | +| `Bindings` | `Record` — the loose shape `normalize` accepts | diff --git a/packages/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 0000000..b6347c9 --- /dev/null +++ b/packages/svelte/package.json @@ -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" + } +} diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts new file mode 100644 index 0000000..fc9954e --- /dev/null +++ b/packages/svelte/src/index.ts @@ -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' diff --git a/packages/svelte/src/merge-props.ts b/packages/svelte/src/merge-props.ts new file mode 100644 index 0000000..dd6b73a --- /dev/null +++ b/packages/svelte/src/merge-props.ts @@ -0,0 +1,71 @@ +type AnyProps = Record +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 +} diff --git a/packages/svelte/src/normalize.ts b/packages/svelte/src/normalize.ts new file mode 100644 index 0000000..43723fb --- /dev/null +++ b/packages/svelte/src/normalize.ts @@ -0,0 +1,180 @@ +/** + * Translate the machine layer's LOGICAL surface to Svelte DOM props. + * + * Logical handler → DOM event prop + * Logical attr → DOM/ARIA attr + * + * Differences from the React DOM normalizer worth flagging: + * + * - Svelte 5 event props are the lowercase DOM attribute names (`onclick`, + * `onkeydown`, `onpointerenter`), not React's camelCase synthetic-event props + * (`onClick`, `onKeyDown`). Spread onto an element, `{...normalize(api.x)}` + * attaches real DOM listeners. + * - `focusable` → `tabindex` (lowercase), where React used `tabIndex`. + * - The `aria-*` attribute names are identical to React's — ARIA is part of the + * DOM, not the framework — so the attr map matches the React one verbatim. + * - The payload adapters are identical too: Svelte hands the handler the raw DOM + * event, the same shape React's normalizer adapts from. + */ + +const HANDLER_MAP: Record = { + onPress: 'onclick', + onPointerEnter: 'onpointerenter', + onPointerLeave: 'onpointerleave', + onPointerMove: 'onpointermove', + onPointerDown: 'onpointerdown', + onPointerUp: 'onpointerup', + onPointerCancel: 'onpointercancel', + onFocus: 'onfocus', + onBlur: 'onblur', + onKeyDown: 'onkeydown', + onKeyUp: 'onkeyup', + // value-change + secondary/double activation + scroll/wheel. onValueChange/ + // onWheel/onScroll/onScrollEnd additionally have their argument translated + // from the raw DOM event into the agnostic payload (see PAYLOAD_ADAPTERS). + onValueChange: 'oninput', + onContextMenu: 'oncontextmenu', + onDoublePress: 'ondblclick', + onWheel: 'onwheel', + onScroll: 'onscroll', + onScrollEnd: 'onscrollend', +} + +// Some handlers can't just be renamed: the agnostic payload the component reads +// (`ChangePayload`/`WheelPayload`/`ScrollPayload`) is a different SHAPE from the +// raw DOM event. For those, normalize wraps the handler so the component +// receives the agnostic payload — built here from the DOM event — rather than +// the DOM event itself. (onPress/pointer/keyboard handlers already receive a +// shape that overlaps PointerPayload/KeyboardPayload, so they pass through +// unwrapped, exactly as onPress always has.) + +// DOM WheelEvent.deltaMode (0/1/2) → the neutral WheelPayload unit. +const WHEEL_UNIT = ['pixel', 'line', 'page'] as const + +type AnyEvent = { + target?: { value?: unknown; checked?: unknown; type?: string } + currentTarget?: Record + deltaX?: number + deltaY?: number + deltaZ?: number + deltaMode?: number + defaultPrevented?: boolean + preventDefault?: () => void +} + +const PAYLOAD_ADAPTERS: Record unknown> = { + onValueChange: e => { + const t = e?.target + // checkbox/radio carry the boolean on `.checked`; everything else on `.value`. + const value = t && (t.type === 'checkbox' || t.type === 'radio') ? t.checked : t?.value + return { value, defaultPrevented: e?.defaultPrevented, preventDefault: e?.preventDefault } + }, + onWheel: e => ({ + deltaX: e?.deltaX, + deltaY: e?.deltaY, + deltaZ: e?.deltaZ, + deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel', + defaultPrevented: e?.defaultPrevented, + preventDefault: e?.preventDefault, + }), + onScroll: scrollPayload, + onScrollEnd: scrollPayload, +} + +function scrollPayload(e: AnyEvent): unknown { + const el = e?.currentTarget ?? {} + return { + offsetX: el.scrollLeft, + offsetY: el.scrollTop, + contentWidth: el.scrollWidth, + contentHeight: el.scrollHeight, + viewportWidth: el.clientWidth, + viewportHeight: el.clientHeight, + } +} + +const ATTR_MAP: Record = { + describedBy: 'aria-describedby', + labelledBy: 'aria-labelledby', + controls: 'aria-controls', + hasPopup: 'aria-haspopup', + expanded: 'aria-expanded', + selected: 'aria-selected', + disabled: 'aria-disabled', + hidden: 'aria-hidden', + modal: 'aria-modal', + focusable: 'tabindex', // value transformed below + role: 'role', + id: 'id', + + // labeling + label: 'aria-label', + // widget state (values pass through untransformed — booleans, the 'mixed' + // tristate, and the aria-current / aria-invalid enums all serialize as-is) + checked: 'aria-checked', + pressed: 'aria-pressed', + current: 'aria-current', + busy: 'aria-busy', + invalid: 'aria-invalid', + required: 'aria-required', + readOnly: 'aria-readonly', + // relationships + activeDescendant: 'aria-activedescendant', + errorMessage: 'aria-errormessage', + owns: 'aria-owns', + // value / range + valueMin: 'aria-valuemin', + valueMax: 'aria-valuemax', + valueNow: 'aria-valuenow', + valueText: 'aria-valuetext', + // structure / orientation + orientation: 'aria-orientation', + sort: 'aria-sort', + autoComplete: 'aria-autocomplete', + multiline: 'aria-multiline', + multiSelectable: 'aria-multiselectable', + level: 'aria-level', + posInSet: 'aria-posinset', + setSize: 'aria-setsize', + // grid / table + colCount: 'aria-colcount', + colIndex: 'aria-colindex', + colSpan: 'aria-colspan', + rowCount: 'aria-rowcount', + rowIndex: 'aria-rowindex', + rowSpan: 'aria-rowspan', + // live region + live: 'aria-live', + atomic: 'aria-atomic', +} + +export type Bindings = Record + +export function normalize(logical: Bindings): Record { + const out: Record = {} + for (const [key, value] of Object.entries(logical)) { + if (value === undefined) continue + + const handler = HANDLER_MAP[key] + if (handler) { + const adapt = PAYLOAD_ADAPTERS[key] + // Wrap when the agnostic payload differs from the raw DOM event; else the + // handler shape already matches (PointerPayload/KeyboardPayload), pass it. + out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value + continue + } + + const attr = ATTR_MAP[key] + if (attr) { + if (key === 'focusable') { + out[attr] = value ? 0 : -1 + } else { + out[attr] = value + } + continue + } + + out[key] = value + } + return out +} diff --git a/packages/svelte/src/use-machine.svelte.ts b/packages/svelte/src/use-machine.svelte.ts new file mode 100644 index 0000000..c60f20a --- /dev/null +++ b/packages/svelte/src/use-machine.svelte.ts @@ -0,0 +1,149 @@ +/// +import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' + +/** + * One substrate-specific effect, declared as a plain setup/teardown function + * plus the prop names it depends on: + * + * const escape: ComponentEffect = [ + * (machine, props) => { ...addEventListener...; return () => ...remove... }, + * ['closeOnEscape', 'onEscapeKeyDown'], // re-run when these props change + * ] + * + * The author writes no Svelte. The deps are prop NAMES (typed `(keyof Props)[]`, + * so typos are compile errors); the bridge turns them into a tracked `$effect` + * so the effect re-subscribes only when one of those props actually changes — + * not on every change, never stale. `machine` is always an implicit dep. + * + * Identical in shape to the React `ComponentEffect` — what changes is only how + * `useMachine` runs it (a Svelte `$effect`, not a React `useEffect`). + */ +export type ComponentEffect = [ + effect: (machine: Machine, props: Props) => (() => void) | void, + deps: (keyof Props)[], +] + +/** + * A component's full set of substrate effects — a list, since one component can + * have several independent effects with DIFFERENT deps (e.g. an Escape listener + * gated by `closeOnEscape` and a Tab trap gated by `focusTrap`). Each gets its + * own `$effect` so only the one whose dep changed re-subscribes. + * + * Unlike React, Svelte has no rules-of-hooks: `useMachine` sets up the effects + * once at call time, so the list need not be a module constant. Keeping it one + * (`export const xEffects = [...]`) is still the tidy convention. + */ +export type ComponentEffects = ComponentEffect[] + +type Service< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +> = ReturnType> + +/** + * The one generic Svelte bridge. Every component's generated api calls this with + * the agnostic pieces — a config factory and the connect — plus the component's + * substrate effects and a GETTER for the resolved props: + * + * const view = useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, () => props) + * // then in markup: diff --git a/packages/svelte/tests/fixtures/toggle.ts b/packages/svelte/tests/fixtures/toggle.ts new file mode 100644 index 0000000..7459b17 --- /dev/null +++ b/packages/svelte/tests/fixtures/toggle.ts @@ -0,0 +1,50 @@ +import { act, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' + +/** + * A tiny toggle machine used by the useMachine tests — the Svelte counterpart of + * the fixture the React bridge tests use. `open` flips state and counts toggles; + * `label` is a pass-through prop that proves prop changes flow through `setProps` + * (and don't rebuild the machine). + */ + +export type ToggleState = 'closed' | 'open' +export type ToggleContext = { count: number } +export type ToggleEvent = { type: 'toggle' } +export type ToggleProps = { label?: string } +export type ToggleApi = { + open: boolean + count: number + label: string | undefined + toggle: () => void +} + +export const createToggleConfig = (): TransitionConfig< + ToggleState, + ToggleContext, + ToggleEvent +> => ({ + initial: 'closed', + context: { count: 0 }, + states: { + // The count write goes through `act` (setContext) so subscribers wake. + closed: { + on: { toggle: { target: 'open', actions: act($ => ({ count: $.context.count + 1 })) } }, + }, + open: { + on: { toggle: { target: 'closed', actions: act($ => ({ count: $.context.count + 1 })) } }, + }, + }, +}) + +export const connectToggle: Connect< + ToggleState, + ToggleContext, + ToggleEvent, + ToggleProps, + ToggleApi +> = ({ state, context, props, send }) => ({ + open: state === 'open', + count: context.count, + label: props.label, + toggle: () => send({ type: 'toggle' }), +}) diff --git a/packages/svelte/tests/merge-props.test.ts b/packages/svelte/tests/merge-props.test.ts new file mode 100644 index 0000000..3d10078 --- /dev/null +++ b/packages/svelte/tests/merge-props.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { mergeProps } from '@dunky.dev/state-machine-svelte' + +describe('mergeProps', () => { + it('chains overlapping handlers — consumer first, library second', () => { + const calls: string[] = [] + const consumer = vi.fn(() => calls.push('consumer')) + const library = vi.fn(() => calls.push('library')) + const merged = mergeProps({ onclick: consumer }, { onclick: library }) + ;(merged.onclick as (e: unknown) => void)({ defaultPrevented: false }) + expect(calls).toEqual(['consumer', 'library']) + }) + + it('skips the library handler when the consumer prevents default', () => { + const consumer = vi.fn() + const library = vi.fn() + const merged = mergeProps({ onclick: consumer }, { onclick: library }) + ;(merged.onclick as (e: unknown) => void)({ defaultPrevented: true }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).not.toHaveBeenCalled() + }) + + it('only chains lowercase on* props (Svelte event shape), not camelCase', () => { + const consumer = vi.fn() + const library = vi.fn() + // camelCase isn't a Svelte DOM event prop → library wins, no chaining. + const merged = mergeProps({ onClick: consumer }, { onClick: library }) + expect(merged.onClick).toBe(library) + }) + + it('library wins on plain attrs', () => { + const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) + expect(out.id).toBe('lib') + }) + + it('concatenates overlapping styles as a string (consumer first, library second)', () => { + const out = mergeProps({ style: 'color: red' }, { style: 'background: blue' }) + expect(out.style).toBe('color: red; background: blue') + }) + + it('drops a trailing semicolon on the consumer style before joining', () => { + const out = mergeProps({ style: 'color: red;' }, { style: 'background: blue' }) + expect(out.style).toBe('color: red; background: blue') + }) + + it('library style wins when consumer omits style', () => { + const out = mergeProps({ id: 'a' }, { style: 'color: blue' }) + expect(out.style).toBe('color: blue') + }) + + it('consumer style stays when library omits style', () => { + const out = mergeProps({ style: 'color: red' }, { id: 'a' }) + expect(out.style).toBe('color: red') + }) + + it('concatenates overlapping class with a single space', () => { + const out = mergeProps({ class: 'a b' }, { class: 'c' }) + expect(out.class).toBe('a b c') + }) + + it('trims edge whitespace on class; inner spacing is preserved verbatim', () => { + const out = mergeProps({ class: ' a ' }, { class: ' b ' }) + // `${' a '} ${' b '}` → ' a b ' → trim → 'a b' + expect(out.class).toBe('a b') + }) + + it('non-string class falls back to library-wins (no concat)', () => { + const out = mergeProps({ id: 'a' }, { class: 'x' }) + expect(out.class).toBe('x') + }) + + it('returns the library props as-is when the consumer passes none', () => { + const library = { id: 'lib', onclick: vi.fn() } + expect(mergeProps(undefined, library)).toBe(library) + }) +}) diff --git a/packages/svelte/tests/normalize.test.ts b/packages/svelte/tests/normalize.test.ts new file mode 100644 index 0000000..5cadfa2 --- /dev/null +++ b/packages/svelte/tests/normalize.test.ts @@ -0,0 +1,308 @@ +/** + * Svelte DOM bindings translator — pure-logic tests (no DOM runtime needed). + * + * `normalize` maps the core's substrate-agnostic logical surface + * (`@dunky.dev/state-machine`'s `EventBindings` + `AttrBindings`) to real + * DOM/ARIA props in Svelte's idiom: lowercase `on*` event props, `tabindex`, + * and the (framework-neutral) `aria-*` attrs. These tests pin the FULL + * vocabulary so every logical binding has an explicit, asserted DOM target — + * nothing relies on accidental pass-through. + */ +import { describe, expect, it, vi } from 'vitest' +import { normalize } from '@dunky.dev/state-machine-svelte' + +describe('svelte normalize — handlers', () => { + it('maps onPress to onclick (the DOM activation event)', () => { + const onPress = vi.fn() + expect(normalize({ onPress })).toEqual({ onclick: onPress }) + }) + + it('maps the full pointer family to lowercase DOM pointer events', () => { + const onPress = vi.fn() + const out = normalize({ + onPointerEnter: vi.fn(), + onPointerLeave: vi.fn(), + onPointerMove: vi.fn(), + onPointerDown: vi.fn(), + onPointerUp: vi.fn(), + onPointerCancel: vi.fn(), + onPress, + }) + expect(Object.keys(out).sort()).toEqual( + [ + 'onpointerenter', + 'onpointerleave', + 'onpointermove', + 'onpointerdown', + 'onpointerup', + 'onpointercancel', + 'onclick', + ].sort(), + ) + }) + + it('maps onFocus / onBlur to lowercase', () => { + const onFocus = vi.fn() + const onBlur = vi.fn() + expect(normalize({ onFocus, onBlur })).toEqual({ onfocus: onFocus, onblur: onBlur }) + }) + + it('maps both keyboard handlers (onkeydown / onkeyup)', () => { + const onKeyDown = vi.fn() + const onKeyUp = vi.fn() + expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onkeydown: onKeyDown, onkeyup: onKeyUp }) + }) +}) + +describe('svelte normalize — attributes', () => { + it('maps the ARIA reference attrs (describedBy / labelledBy / controls)', () => { + expect(normalize({ describedBy: 'd', labelledBy: 'l', controls: 'c' })).toEqual({ + 'aria-describedby': 'd', + 'aria-labelledby': 'l', + 'aria-controls': 'c', + }) + }) + + it('maps hasPopup to aria-haspopup (string or boolean)', () => { + expect(normalize({ hasPopup: 'menu' })).toEqual({ 'aria-haspopup': 'menu' }) + expect(normalize({ hasPopup: true })).toEqual({ 'aria-haspopup': true }) + }) + + it('maps the boolean state attrs to their aria-* equivalents', () => { + expect( + normalize({ expanded: true, selected: false, disabled: true, hidden: false, modal: true }), + ).toEqual({ + 'aria-expanded': true, + 'aria-selected': false, + 'aria-disabled': true, + 'aria-hidden': false, + 'aria-modal': true, + }) + }) + + it('maps focusable to tabindex (true → 0, false → -1)', () => { + expect(normalize({ focusable: true })).toEqual({ tabindex: 0 }) + expect(normalize({ focusable: false })).toEqual({ tabindex: -1 }) + }) + + it('maps role and id straight through (same name)', () => { + expect(normalize({ role: 'tooltip', id: 't:1' })).toEqual({ role: 'tooltip', id: 't:1' }) + }) + + it('passes unknown attrs through unchanged (e.g. data-state)', () => { + expect(normalize({ 'data-state': 'open' })).toEqual({ 'data-state': 'open' }) + }) + + it('skips undefined values', () => { + expect(normalize({ role: undefined, id: 'x' })).toEqual({ id: 'x' }) + }) +}) + +describe('svelte normalize — combined surface (trigger shape)', () => { + it('translates a realistic trigger binding set', () => { + const onPress = vi.fn() + const out = normalize({ + id: 'menu:1:trigger', + role: 'button', + controls: 'menu:1:content', + hasPopup: 'menu', + expanded: true, + focusable: true, + onPress, + onKeyDown: vi.fn(), + 'data-state': 'open', + }) + expect(out).toMatchObject({ + id: 'menu:1:trigger', + role: 'button', + 'aria-controls': 'menu:1:content', + 'aria-haspopup': 'menu', + 'aria-expanded': true, + tabindex: 0, + onclick: onPress, + 'data-state': 'open', + }) + expect(typeof out.onkeydown).toBe('function') + }) +}) + +describe('svelte normalize — expanded handler surface', () => { + it('maps each value-change / interaction handler to its lowercase DOM event prop', () => { + const out = normalize({ + onValueChange: vi.fn(), + onContextMenu: vi.fn(), + onDoublePress: vi.fn(), + onWheel: vi.fn(), + onScroll: vi.fn(), + onScrollEnd: vi.fn(), + }) + expect(Object.keys(out).sort()).toEqual( + ['oninput', 'oncontextmenu', 'ondblclick', 'onscroll', 'onscrollend', 'onwheel'].sort(), + ) + }) + + it('passes onContextMenu / onDoublePress through unwrapped (same payload shape)', () => { + const onContextMenu = vi.fn() + const onDoublePress = vi.fn() + const out = normalize({ onContextMenu, onDoublePress }) + expect(out.oncontextmenu).toBe(onContextMenu) + expect(out.ondblclick).toBe(onDoublePress) + }) + + it('onValueChange receives a ChangePayload built from the DOM event', () => { + const onValueChange = vi.fn() + const out = normalize({ onValueChange }) + ;(out.oninput as (e: unknown) => void)({ target: { value: 'hi', type: 'text' } }) + expect(onValueChange).toHaveBeenCalledWith({ + value: 'hi', + defaultPrevented: undefined, + preventDefault: undefined, + }) + ;(out.oninput as (e: unknown) => void)({ target: { checked: true, type: 'checkbox' } }) + expect(onValueChange).toHaveBeenLastCalledWith(expect.objectContaining({ value: true })) + }) + + it('onWheel receives a WheelPayload with a neutral deltaUnit (deltaMode → enum)', () => { + const onWheel = vi.fn() + const out = normalize({ onWheel }) + ;(out.onwheel as (e: unknown) => void)({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaMode: 1 }) + expect(onWheel).toHaveBeenCalledWith( + expect.objectContaining({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaUnit: 'line' }), + ) + }) + + it('onScroll / onScrollEnd receive a neutral ScrollPayload from currentTarget geometry', () => { + const onScroll = vi.fn() + const out = normalize({ onScroll }) + ;(out.onscroll as (e: unknown) => void)({ + currentTarget: { + scrollLeft: 5, + scrollTop: 50, + scrollWidth: 800, + scrollHeight: 1200, + clientWidth: 400, + clientHeight: 600, + }, + }) + expect(onScroll).toHaveBeenCalledWith({ + offsetX: 5, + offsetY: 50, + contentWidth: 800, + contentHeight: 1200, + viewportWidth: 400, + viewportHeight: 600, + }) + }) +}) + +describe('svelte normalize — expanded attribute surface', () => { + it('maps widget-state attrs to aria-*, preserving tristate/enum values', () => { + expect( + normalize({ + checked: 'mixed', + pressed: true, + current: 'page', + busy: true, + invalid: 'spelling', + required: true, + readOnly: false, + }), + ).toEqual({ + 'aria-checked': 'mixed', + 'aria-pressed': true, + 'aria-current': 'page', + 'aria-busy': true, + 'aria-invalid': 'spelling', + 'aria-required': true, + 'aria-readonly': false, + }) + }) + + it('maps labeling + relationship attrs', () => { + expect( + normalize({ label: 'Volume', activeDescendant: 'opt-3', errorMessage: 'e1', owns: 'lb1' }), + ).toEqual({ + 'aria-label': 'Volume', + 'aria-activedescendant': 'opt-3', + 'aria-errormessage': 'e1', + 'aria-owns': 'lb1', + }) + }) + + it('maps value/range attrs (slider shape)', () => { + expect(normalize({ valueMin: 0, valueMax: 100, valueNow: 70, valueText: '70%' })).toEqual({ + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': 70, + 'aria-valuetext': '70%', + }) + }) + + it('maps structure + grid attrs', () => { + expect( + normalize({ + orientation: 'horizontal', + sort: 'ascending', + autoComplete: 'list', + multiline: true, + multiSelectable: false, + level: 2, + posInSet: 3, + setSize: 10, + colCount: 5, + colIndex: 2, + colSpan: 1, + rowCount: 20, + rowIndex: 4, + rowSpan: 1, + }), + ).toEqual({ + 'aria-orientation': 'horizontal', + 'aria-sort': 'ascending', + 'aria-autocomplete': 'list', + 'aria-multiline': true, + 'aria-multiselectable': false, + 'aria-level': 2, + 'aria-posinset': 3, + 'aria-setsize': 10, + 'aria-colcount': 5, + 'aria-colindex': 2, + 'aria-colspan': 1, + 'aria-rowcount': 20, + 'aria-rowindex': 4, + 'aria-rowspan': 1, + }) + }) + + it('maps live-region attrs (off passes through as aria-live="off")', () => { + expect(normalize({ live: 'off', atomic: true })).toEqual({ + 'aria-live': 'off', + 'aria-atomic': true, + }) + }) + + it('translates a realistic slider binding set', () => { + const onValueChange = vi.fn() + const out = normalize({ + role: 'slider', + orientation: 'horizontal', + valueMin: 0, + valueMax: 100, + valueNow: 40, + valueText: '40%', + focusable: true, + onValueChange, + }) + expect(out).toMatchObject({ + role: 'slider', + 'aria-orientation': 'horizontal', + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuenow': 40, + 'aria-valuetext': '40%', + tabindex: 0, + }) + ;(out.oninput as (e: unknown) => void)({ target: { value: '50', type: 'range' } }) + expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: '50' })) + }) +}) diff --git a/packages/svelte/tests/use-machine.test.ts b/packages/svelte/tests/use-machine.test.ts new file mode 100644 index 0000000..44d9995 --- /dev/null +++ b/packages/svelte/tests/use-machine.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { render, fireEvent } from '@testing-library/svelte' +import { tick } from 'svelte' +import ToggleHarness from './fixtures/toggle-harness.svelte' + +type Sink = { + api?: import('./fixtures/toggle').ToggleApi + renders: number + effectRuns: number + effectCleanups: number +} + +const newSink = (): Sink => ({ renders: 0, effectRuns: 0, effectCleanups: 0 }) + +describe('useMachine', () => { + it('exposes the connect() api and drives the markup', async () => { + const sink = newSink() + const { getByTestId } = render(ToggleHarness, { props: { label: 'a', sink } }) + await tick() + + const btn = getByTestId('toggle') + expect(btn.textContent).toContain('a') + expect(btn.textContent).toContain('closed') + expect(sink.api?.open).toBe(false) + }) + + it('builds the machine ONCE: state survives prop changes', async () => { + const sink = newSink() + const { getByTestId, rerender } = render(ToggleHarness, { props: { label: 'a', sink } }) + await tick() + + await fireEvent.click(getByTestId('toggle')) // → open, count 1 + await tick() + expect(sink.api?.open).toBe(true) + expect(sink.api?.count).toBe(1) + + // A prop change must NOT rebuild the machine — state + count persist, and the + // fresh label flows through setProps. + await rerender({ label: 'b', sink }) + await tick() + expect(sink.api?.open).toBe(true) + expect(sink.api?.count).toBe(1) + expect(sink.api?.label).toBe('b') + }) + + it('produces one new snapshot per real machine change', async () => { + const sink = newSink() + const { getByTestId } = render(ToggleHarness, { props: { label: 'a', sink } }) + await tick() + const baseline = sink.renders + const firstApi = sink.api + + // Each toggle is a real state change → exactly one new snapshot (the + // connector memoizes, so the identity changes only on a real change). + await fireEvent.click(getByTestId('toggle')) + await tick() + expect(sink.renders).toBe(baseline + 1) + expect(sink.api).not.toBe(firstApi) + }) + + it('runs a component effect on mount and cleans it up on unmount', async () => { + const sink = newSink() + const { unmount } = render(ToggleHarness, { + props: { label: 'a', sink, trackLabelEffect: true }, + }) + await tick() + expect(sink.effectRuns).toBe(1) + expect(sink.effectCleanups).toBe(0) + + unmount() + await tick() + expect(sink.effectCleanups).toBe(1) + }) + + it('does not re-run a prop-scoped effect on an unrelated machine change', async () => { + const sink = newSink() + const { getByTestId } = render(ToggleHarness, { + props: { label: 'a', sink, trackLabelEffect: true }, + }) + await tick() + expect(sink.effectRuns).toBe(1) + + // Toggling changes machine state, NOT the `label` prop the effect tracks — + // so the effect stays put. This is the precise-dependency guarantee: the + // effect reads only its named props, so runes wake it only for those. + await fireEvent.click(getByTestId('toggle')) + await tick() + expect(sink.effectRuns).toBe(1) + }) + + it('re-runs an effect when its named prop dep changes (cleaning up first)', async () => { + const sink = newSink() + const { rerender } = render(ToggleHarness, { + props: { label: 'a', sink, trackLabelEffect: true }, + }) + await tick() + expect(sink.effectRuns).toBe(1) + + // dep changed → re-run, and the prior setup is torn down first. + await rerender({ label: 'b', sink, trackLabelEffect: true }) + await tick() + expect(sink.effectRuns).toBe(2) + expect(sink.effectCleanups).toBe(1) + }) +}) diff --git a/packages/svelte/tests/use-selector.test.ts b/packages/svelte/tests/use-selector.test.ts new file mode 100644 index 0000000..7019058 --- /dev/null +++ b/packages/svelte/tests/use-selector.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/svelte' +import { tick } from 'svelte' +import SelectorHarness from './fixtures/selector-harness.svelte' +import { makeCounters } from './fixtures/counters' + +describe('useSelector', () => { + it('reads the current selected value on mount', async () => { + const m = makeCounters() + const sink = { value: undefined as unknown, updates: 0 } + const { getByTestId } = render(SelectorHarness, { + props: { machine: m, selector: () => m.context.a, sink }, + }) + await tick() + expect(getByTestId('value').textContent).toBe('0') + expect(sink.value).toBe(0) + }) + + it('updates ONLY when the selected slice changes', async () => { + const m = makeCounters() + const sink = { value: undefined as unknown, updates: 0 } + render(SelectorHarness, { props: { machine: m, selector: () => m.context.a, sink } }) + await tick() + const baseline = sink.updates + + // selects `a`; bumping `b` must NOT update this selection. + m.send({ type: 'incB' }) + await tick() + expect(sink.updates).toBe(baseline) + expect(sink.value).toBe(0) + + // bumping `a` updates it. + m.send({ type: 'incA' }) + await tick() + expect(sink.updates).toBe(baseline + 1) + expect(sink.value).toBe(1) + }) + + it('uses the provided isEqual to dedup an object selection', async () => { + const m = makeCounters() + const sink = { value: undefined as unknown, updates: 0 } + render(SelectorHarness, { + props: { + machine: m, + selector: () => ({ a: m.context.a }), + isEqual: (x: unknown, y: unknown) => (x as { a: number }).a === (y as { a: number }).a, + sink, + }, + }) + await tick() + const baseline = sink.updates + + // `b` changes, the selected `{ a }` is equal under isEqual → no update, + // despite the selector returning a fresh object each evaluation. + m.send({ type: 'incB' }) + await tick() + expect(sink.updates).toBe(baseline) + + // `a` changes → the object differs under isEqual → one update. + m.send({ type: 'incA' }) + await tick() + expect(sink.updates).toBe(baseline + 1) + expect((sink.value as { a: number }).a).toBe(1) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f59d3f..b7527d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@22.19.19) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.1.2 + version: 7.1.2(svelte@5.56.4)(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)) + '@testing-library/svelte': + specifier: ^5.4.2 + version: 5.4.2(svelte@5.56.4)(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))(vitest@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))) '@types/node': specifier: ^22.10.2 version: 22.19.19 @@ -28,7 +34,7 @@ importers: version: 17.0.7 oxfmt: specifier: ^0.52.0 - version: 0.52.0 + version: 0.52.0(svelte@5.56.4) oxlint: specifier: ^1.67.0 version: 1.67.0 @@ -180,6 +186,16 @@ importers: packages/shared/utils: {} + packages/svelte: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:^ + version: link:../core + devDependencies: + svelte: + specifier: ^5.43.2 + version: 5.56.4 + sandbox/native: dependencies: '@dunky.dev/state-machine': @@ -313,10 +329,10 @@ importers: version: link:../packages/core '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(react@19.2.6) + version: 2.0.1(react@19.2.6)(svelte@5.56.4) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(react@19.2.6) + version: 2.0.0(react@19.2.6)(svelte@5.56.4) devDependencies: '@astrojs/mdx': specifier: ^6.0.3 @@ -2783,6 +2799,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte@7.1.2': + resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} @@ -2890,6 +2918,25 @@ packages: '@types/react-dom': optional: true + '@testing-library/svelte-core@1.1.3': + resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + + '@testing-library/svelte@5.4.2': + resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -2991,6 +3038,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3244,6 +3294,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3903,11 +3957,22 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esrap@2.2.12: + resolution: {integrity: sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -4437,6 +4502,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -4652,6 +4720,9 @@ packages: resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} engines: {node: '>=22.13.0'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -6045,6 +6116,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.56.4: + resolution: {integrity: sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==} + engines: {node: '>=18'} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -6744,6 +6819,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -9288,6 +9366,19 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(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))': + dependencies: + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.56.4 + 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) + vitefu: 1.1.3(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)) + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -9378,6 +9469,19 @@ snapshots: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) + '@testing-library/svelte-core@1.1.3(svelte@5.56.4)': + dependencies: + svelte: 5.56.4 + + '@testing-library/svelte@5.4.2(svelte@5.56.4)(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))(vitest@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)))': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.1.3(svelte@5.56.4) + svelte: 5.56.4 + optionalDependencies: + 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) + vitest: 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)) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -9497,6 +9601,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -9521,13 +9627,15 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.6 - '@vercel/analytics@2.0.1(react@19.2.6)': + '@vercel/analytics@2.0.1(react@19.2.6)(svelte@5.56.4)': optionalDependencies: react: 19.2.6 + svelte: 5.56.4 - '@vercel/speed-insights@2.0.0(react@19.2.6)': + '@vercel/speed-insights@2.0.0(react@19.2.6)(svelte@5.56.4)': optionalDependencies: react: 19.2.6 + svelte: 5.56.4 '@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(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))': dependencies: @@ -9699,6 +9807,8 @@ snapshots: dependencies: dequal: 2.0.3 + aria-query@5.3.1: {} + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -10476,8 +10586,14 @@ snapshots: escape-string-regexp@5.0.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} + esrap@2.2.12: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.9 @@ -11135,6 +11251,10 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -11388,6 +11508,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -12665,7 +12787,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 - oxfmt@0.52.0: + oxfmt@0.52.0(svelte@5.56.4): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -12688,6 +12810,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.52.0 '@oxfmt/binding-win32-ia32-msvc': 0.52.0 '@oxfmt/binding-win32-x64-msvc': 0.52.0 + svelte: 5.56.4 oxlint@1.67.0: optionalDependencies: @@ -13647,6 +13770,27 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.56.4: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.12 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -13997,6 +14141,10 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu@1.1.3(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)): + optionalDependencies: + 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) + vitest@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)): dependencies: '@vitest/expect': 4.1.7 @@ -14168,6 +14316,8 @@ snapshots: yocto-queue@1.2.2: {} + zimmerframe@1.1.4: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/tsconfig.json b/tsconfig.json index 7dbe31a..aaba079 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@dunky.dev/state-machine-react": ["./packages/react/src"], "@dunky.dev/state-machine-native": ["./packages/native/src"], "@dunky.dev/state-machine-opentui": ["./packages/opentui/src"], + "@dunky.dev/state-machine-svelte": ["./packages/svelte/src"], "@dunky.dev/state-machine-utils": ["./packages/shared/utils/src"], "@dunky.dev/state-machine-bindings": ["./packages/shared/bindings/src"] }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 99165b5..b4a5b7c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -9,6 +9,12 @@ export default defineConfig({ // src/tests dirs, and `include: 'auto'` walks node_modules + grabs the non-published // benchmark/website packages — so for this layout an explicit list is the clean // choice. Keep in sync with the publish set in .changeset/config.json. + // + // `packages/svelte` is intentionally absent: it ships its `src` uncompiled so + // the consumer's Svelte compiler can process its runes (`.svelte.ts`) modules — + // tsdown doesn't run the Svelte compiler, and pre-compiling would strip the + // runes the downstream build needs to see. Its `exports` point straight at + // `src` (no `dist`), so it has no build step here. workspace: [ 'packages/core', 'packages/react', diff --git a/vitest.config.ts b/vitest.config.ts index c3bca12..2c37140 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,39 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' import { defineConfig } from 'vitest/config' +// Two projects. The default one keeps the original plain-node setup for every +// package (the react tests opt into jsdom per-file via `// @vitest-environment +// jsdom`, which needs no plugin since esbuild handles JSX). The `svelte` project +// is scoped to packages/svelte and brings the Svelte compiler (so `.svelte` test +// components and `.svelte.ts` runes modules are transformed) plus jsdom, which +// `@testing-library/svelte` renders into. export default defineConfig({ test: { - globals: false, - environment: 'node', - exclude: ['**/node_modules/**', '**/dist/**'], + projects: [ + { + test: { + name: 'default', + globals: false, + environment: 'node', + include: ['**/*.test.{ts,tsx}'], + exclude: ['**/node_modules/**', '**/dist/**', 'packages/svelte/**'], + }, + }, + { + // `svelteTesting()` compiles the testing-library helpers themselves + // (their own `.svelte.js`) and resolves Svelte's browser/client build so + // runes run under jsdom — without it `$state` throws `rune_outside_svelte`. + // It also wires automatic cleanup between tests. + plugins: [svelte(), svelteTesting()], + test: { + name: 'svelte', + globals: false, + environment: 'jsdom', + include: ['packages/svelte/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + }, + }, + ], }, }) diff --git a/website/astro.config.ts b/website/astro.config.ts index 7e05607..acc0fae 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -111,6 +111,7 @@ export default defineConfig({ label: 'Integrations', items: [ { label: 'React', link: 'libs/react' }, + { label: 'Svelte', link: 'libs/svelte' }, { label: 'React Native', link: 'libs/react-native' }, { label: 'OpenTUI', link: 'libs/opentui' }, ], diff --git a/website/src/content/docs/libs/svelte.mdx b/website/src/content/docs/libs/svelte.mdx new file mode 100644 index 0000000..b00cc8b --- /dev/null +++ b/website/src/content/docs/libs/svelte.mdx @@ -0,0 +1,188 @@ +--- +title: Svelte +description: Svelte bindings for @dunky.dev/state-machine. +--- + +import Install from '../../../components/install.astro' + + + +The Svelte package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Svelte 5: lifecycle, reactivity (runes), prop translation, and platform effects. It requires Svelte 5 — `useMachine` and `useSelector` are runes modules (`.svelte.ts`), so they're compiled by your Svelte build like any component. + +## `useMachine` + +The one bridge. Every component calls it with the four agnostic pieces and gets back the view API: + +```svelte + + + +{#if view.api.isOpen} +
Dialog content
+{/if} +``` + +`useMachine` builds the machine and connector **once** (the first props read seeds context; later changes flow through `setProps`, not a rebuild), starts on mount, stops on unmount, and exposes the connector's stable snapshot through `view.api` — a `$state`-backed getter, so reading it in markup updates only on a real change. + +Props are passed as a **getter** (`() => props`), not a value. Svelte props are reactive bindings; the getter lets the bridge read their current form inside its effects — the Svelte analogue of React's per-render `props` argument. There is no `setProps` call in your component. + +### The three imports + +Those three values are where the dialog's behavior actually lives, and none of it is Svelte. You write them once, in a `./dialog` module, and they run unchanged on any platform: + +```ts +// dialog.ts: plain functions, no Svelte +import type { Connect } from '@dunky.dev/state-machine' + +type State = 'closed' | 'open' +type Context = { closeOnEscape: boolean } +type Event = { type: 'open' } | { type: 'close' } +type Api = { + isOpen: boolean + triggerProps: object + contentProps: object +} + +// createDialogConfig: (props) => machine config. Defines the states +// ('closed' | 'open'), the events, and seeds context from the first props. +export const createDialogConfig = (props: DialogProps) => ({ + initial: props.open ? 'open' : 'closed', + context: { closeOnEscape: props.closeOnEscape ?? true }, + states: { + closed: { on: { open: 'open' } }, + open: { on: { close: 'closed' } }, + }, +}) + +// connectDialog: a pure connect() that turns a machine snapshot into the view API +// your markup spreads. `isOpen`, `triggerProps`, `contentProps` come from here. +// The type args are . +export const connectDialog: Connect = ({ + state, + send, +}) => ({ + isOpen: state === 'open', + triggerProps: { + onPress: () => send({ type: 'open' }), + expanded: state === 'open', + }, + contentProps: { role: 'dialog', modal: true }, +}) + +// dialogEffects: DOM listeners that can't live in the machine. Here, one that +// closes the dialog on Escape. `onEscapeKey` is a [setup/teardown, deps] tuple; +// see the ComponentEffect section below for its full body. +export const dialogEffects = [onEscapeKey] +``` + +So `useMachine` is the only Svelte-specific piece: `createDialogConfig` is the machine definition, `connectDialog` is the snapshot-to-view-API mapping, and `dialogEffects` are the DOM listeners. The exact same three modules drive the [React](/libs/react) version. See [Setup](/api/setup) for configs and [Connector](/api/connector) for how `connect` works in depth. + +## `normalize`: bindings → DOM props + +`connect` returns substrate-agnostic bindings (`onPress`, `role`, `describedBy`). `normalize` translates them to real DOM/ARIA props in Svelte's idiom — lowercase `on*` event props, `tabindex`, `aria-*`: + +```ts +normalize(view.api.triggerProps) +// { onclick, 'aria-expanded', role, tabindex, ... } +``` + +| Binding | DOM prop | +| ----------------------------------------------- | ------------------------------------------------------------------- | +| `onPress` | `onclick` | +| `onPointerEnter/Leave/Move/Down` | `onpointerenter` / … (lowercase) | +| `onFocus` / `onBlur` / `onKeyDown` | `onfocus` / `onblur` / `onkeydown` | +| `describedBy` | `aria-describedby` | +| `labelledBy` | `aria-labelledby` | +| `expanded` / `selected` / `disabled` / `hidden` | `aria-expanded` / `aria-selected` / `aria-disabled` / `aria-hidden` | +| `focusable` | `tabindex` (`true → 0`, `false → -1`) | +| `role` / `id` | `role` / `id` | + +The logical names are renderer-neutral by design — `onPress` not `onclick`, `checked` not `aria-checked` — so the same `connect` output drives DOM, React, or React Native, each through its own `normalize`. `undefined` values are dropped. Unknown keys pass through unchanged. + +## `mergeProps`: consumer + component props + +When a consumer spreads their own props onto the same element the component controls: + +```svelte + + + {#if view.api.open} + + {/if} + + + diff --git a/sandbox/svelte/src/main.ts b/sandbox/svelte/src/main.ts new file mode 100644 index 0000000..dec3111 --- /dev/null +++ b/sandbox/svelte/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte' +import App from './app.svelte' + +const target = document.getElementById('app') +if (!target) throw new Error('missing #app') + +mount(App, { target }) diff --git a/sandbox/svelte/svelte.config.js b/sandbox/svelte/svelte.config.js new file mode 100644 index 0000000..af5e7ee --- /dev/null +++ b/sandbox/svelte/svelte.config.js @@ -0,0 +1,6 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Lets ` + + diff --git a/packages/svelte/tests/normalize.dom.test.ts b/packages/svelte/tests/normalize.dom.test.ts new file mode 100644 index 0000000..6fec744 --- /dev/null +++ b/packages/svelte/tests/normalize.dom.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +/** + * Svelte normalize on real elements. Svelte 5 attaches a spread `on*` prop as + * a listener for exactly the event named after `on` (case-sensitive), so this + * proves every mapped handler fires for its DOM event, the adapted payloads + * act on the real event, and the attrs land as the ARIA tokens a host reads. + */ +import { render, within } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import type { HandlerKey } from '@dunky.dev/state-machine-bindings' +import { normalize } from '@dunky.dev/svelte-state-machine' +import { HANDLER_MAP } from '../src/normalize' +import Spread from './fixtures/spread.svelte' + +// The DOM event a user action produces for each logical handler — listed +// independently of HANDLER_MAP, so a mis-cased prop can't pass by agreeing +// with itself. +const DOM_EVENT: Record = { + onPress: 'click', + onPointerEnter: 'pointerenter', + onPointerLeave: 'pointerleave', + onPointerMove: 'pointermove', + onPointerDown: 'pointerdown', + onPointerUp: 'pointerup', + onPointerCancel: 'pointercancel', + onFocus: 'focus', + onBlur: 'blur', + onKeyDown: 'keydown', + onKeyUp: 'keyup', + onValueChange: 'input', + onContextMenu: 'contextmenu', + onDoublePress: 'dblclick', + onWheel: 'wheel', + onScroll: 'scroll', + onScrollEnd: 'scrollend', +} + +// Scoped to its own container: one test may mount several. +const mount = (bindings: Record, tag?: string): HTMLElement => + within(render(Spread, { tag, attrs: normalize(bindings) }).container).getByTestId('target') + +describe('svelte normalize — on a real element', () => { + it('fires every mapped handler for its DOM event', () => { + for (const key of Object.keys(HANDLER_MAP) as HandlerKey[]) { + const handler = vi.fn() + mount({ [key]: handler }).dispatchEvent(new Event(DOM_EVENT[key], { bubbles: true })) + expect({ key, calls: handler.mock.calls.length }).toEqual({ key, calls: 1 }) + } + }) + + it("adapted payloads carry the real event's value and a working preventDefault", () => { + let payload: { value?: unknown; preventDefault?: () => void } | undefined + const input = mount({ onValueChange: (p: typeof payload) => (payload = p) }, 'input') + ;(input as HTMLInputElement).value = 'hi' + const event = new Event('input', { bubbles: true, cancelable: true }) + input.dispatchEvent(event) + + expect(payload?.value).toBe('hi') + payload?.preventDefault?.() // a detached native preventDefault throws Illegal invocation + expect(event.defaultPrevented).toBe(true) + }) + + it('writes ARIA states as literal tokens and focusable as tabindex', () => { + const el = mount({ expanded: false, hidden: true, focusable: false }) + expect(el.getAttribute('aria-expanded')).toBe('false') + expect(el.getAttribute('aria-hidden')).toBe('true') + expect(el.getAttribute('tabindex')).toBe('-1') + }) + + // connect() rebuilds its closures on every snapshot, so the spread must + // always call the latest one — delegated (click) and direct (pointerenter). + it('keeps spread handlers current when the bindings change', async () => { + const stale = vi.fn() + const fresh = vi.fn() + const { getByTestId, rerender } = render(Spread, { + attrs: normalize({ onPress: stale, onPointerEnter: stale }), + }) + await rerender({ attrs: normalize({ onPress: fresh, onPointerEnter: fresh }) }) + + const el = getByTestId('target') + el.dispatchEvent(new Event('click', { bubbles: true })) + el.dispatchEvent(new Event('pointerenter')) + expect(stale).not.toHaveBeenCalled() + expect(fresh).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/svelte/tests/normalize.test.ts b/packages/svelte/tests/normalize.test.ts index df2443b..a4f77bd 100644 --- a/packages/svelte/tests/normalize.test.ts +++ b/packages/svelte/tests/normalize.test.ts @@ -1,15 +1,17 @@ /** * Svelte DOM bindings translator — pure-logic tests (no DOM runtime needed). * - * `normalize` maps the core's substrate-agnostic logical surface - * (`@dunky.dev/state-machine`'s `EventBindings` + `AttrBindings`) to real - * DOM/ARIA props in Svelte's idiom: lowercase `on*` event props, `tabindex`, - * and the (framework-neutral) `aria-*` attrs. These tests pin the FULL - * vocabulary so every logical binding has an explicit, asserted DOM target — - * nothing relies on accidental pass-through. + * `normalize` maps the core's substrate-agnostic logical surface to the props + * a Svelte 5 element spread expects. These tests pin the FULL vocabulary so + * every logical binding has an explicit, asserted target. The differences + * from the React DOM normalizer are deliberate and pinned: event props are + * the lowercase DOM attribute names (`onclick`, `oninput`, `ondblclick`) and + * `focusable` lands on `tabindex`. */ import { describe, expect, it, vi } from 'vitest' import { normalize } from '@dunky.dev/svelte-state-machine' +import { ATTR_MAP, HANDLER_MAP } from '../src/normalize' +import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting' describe('svelte normalize — handlers', () => { it('maps onPress to onclick (the DOM activation event)', () => { @@ -17,37 +19,34 @@ describe('svelte normalize — handlers', () => { expect(normalize({ onPress })).toEqual({ onclick: onPress }) }) - it('maps the full pointer family to lowercase DOM pointer events', () => { - const onPress = vi.fn() - const out = normalize({ - onPointerEnter: vi.fn(), - onPointerLeave: vi.fn(), - onPointerMove: vi.fn(), - onPointerDown: vi.fn(), - onPointerUp: vi.fn(), - onPointerCancel: vi.fn(), - onPress, + it('maps the full pointer family to the lowercase DOM pointer events', () => { + const [enter, leave, move, down, up, cancel] = Array.from({ length: 6 }, () => vi.fn()) + expect( + normalize({ + onPointerEnter: enter, + onPointerLeave: leave, + onPointerMove: move, + onPointerDown: down, + onPointerUp: up, + onPointerCancel: cancel, + }), + ).toEqual({ + onpointerenter: enter, + onpointerleave: leave, + onpointermove: move, + onpointerdown: down, + onpointerup: up, + onpointercancel: cancel, }) - expect(Object.keys(out).sort()).toEqual( - [ - 'onpointerenter', - 'onpointerleave', - 'onpointermove', - 'onpointerdown', - 'onpointerup', - 'onpointercancel', - 'onclick', - ].sort(), - ) }) - it('maps onFocus / onBlur to lowercase', () => { + it('maps onFocus / onBlur to onfocus / onblur', () => { const onFocus = vi.fn() const onBlur = vi.fn() expect(normalize({ onFocus, onBlur })).toEqual({ onfocus: onFocus, onblur: onBlur }) }) - it('maps both keyboard handlers (onkeydown / onkeyup)', () => { + it('maps both keyboard handlers to onkeydown / onkeyup', () => { const onKeyDown = vi.fn() const onKeyUp = vi.fn() expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onkeydown: onKeyDown, onkeyup: onKeyUp }) @@ -68,6 +67,8 @@ describe('svelte normalize — attributes', () => { expect(normalize({ hasPopup: true })).toEqual({ 'aria-haspopup': true }) }) + // Booleans pass through: Svelte writes a non-boolean attribute's `false` as + // the literal "false" token (pinned on the DOM in normalize.dom.test.ts). it('maps the boolean state attrs to their aria-* equivalents', () => { expect( normalize({ expanded: true, selected: false, disabled: true, hidden: false, modal: true }), @@ -80,7 +81,7 @@ describe('svelte normalize — attributes', () => { }) }) - it('maps focusable to tabindex (true → 0, false → -1)', () => { + it('maps focusable to tabindex (lowercase; true → 0, false → -1)', () => { expect(normalize({ focusable: true })).toEqual({ tabindex: 0 }) expect(normalize({ focusable: false })).toEqual({ tabindex: -1 }) }) @@ -89,8 +90,11 @@ describe('svelte normalize — attributes', () => { expect(normalize({ role: 'tooltip', id: 't:1' })).toEqual({ role: 'tooltip', id: 't:1' }) }) - it('passes unknown attrs through unchanged (e.g. data-state)', () => { - expect(normalize({ 'data-state': 'open' })).toEqual({ 'data-state': 'open' }) + it('passes unknown attrs through unchanged (e.g. data-state, class)', () => { + expect(normalize({ 'data-state': 'open', class: 'x' })).toEqual({ + 'data-state': 'open', + class: 'x', + }) }) it('skips undefined values', () => { @@ -98,36 +102,8 @@ describe('svelte normalize — attributes', () => { }) }) -describe('svelte normalize — combined surface (trigger shape)', () => { - it('translates a realistic trigger binding set', () => { - const onPress = vi.fn() - const out = normalize({ - id: 'menu:1:trigger', - role: 'button', - controls: 'menu:1:content', - hasPopup: 'menu', - expanded: true, - focusable: true, - onPress, - onKeyDown: vi.fn(), - 'data-state': 'open', - }) - expect(out).toMatchObject({ - id: 'menu:1:trigger', - role: 'button', - 'aria-controls': 'menu:1:content', - 'aria-haspopup': 'menu', - 'aria-expanded': true, - tabindex: 0, - onclick: onPress, - 'data-state': 'open', - }) - expect(typeof out.onkeydown).toBe('function') - }) -}) - describe('svelte normalize — expanded handler surface', () => { - it('maps each value-change / interaction handler to its lowercase DOM event prop', () => { + it('maps each value-change / interaction handler to its Svelte DOM event prop', () => { const out = normalize({ onValueChange: vi.fn(), onContextMenu: vi.fn(), @@ -137,7 +113,7 @@ describe('svelte normalize — expanded handler surface', () => { onScrollEnd: vi.fn(), }) expect(Object.keys(out).sort()).toEqual( - ['oninput', 'oncontextmenu', 'ondblclick', 'onscroll', 'onscrollend', 'onwheel'].sort(), + ['oncontextmenu', 'ondblclick', 'oninput', 'onscroll', 'onscrollend', 'onwheel'].sort(), ) }) @@ -149,49 +125,13 @@ describe('svelte normalize — expanded handler surface', () => { expect(out.ondblclick).toBe(onDoublePress) }) - it('onValueChange receives a ChangePayload built from the DOM event', () => { + // Payload construction is pinned once in @dunky.dev/state-machine-dom's own + // tests; this only proves normalize WRAPS the handler with its adapter. + it('onValueChange receives the adapted ChangePayload, not the raw event', () => { const onValueChange = vi.fn() const out = normalize({ onValueChange }) ;(out.oninput as (e: unknown) => void)({ target: { value: 'hi', type: 'text' } }) - expect(onValueChange).toHaveBeenCalledWith({ - value: 'hi', - defaultPrevented: undefined, - preventDefault: undefined, - }) - ;(out.oninput as (e: unknown) => void)({ target: { checked: true, type: 'checkbox' } }) - expect(onValueChange).toHaveBeenLastCalledWith(expect.objectContaining({ value: true })) - }) - - it('onWheel receives a WheelPayload with a neutral deltaUnit (deltaMode → enum)', () => { - const onWheel = vi.fn() - const out = normalize({ onWheel }) - ;(out.onwheel as (e: unknown) => void)({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaMode: 1 }) - expect(onWheel).toHaveBeenCalledWith( - expect.objectContaining({ deltaX: 1, deltaY: 2, deltaZ: 0, deltaUnit: 'line' }), - ) - }) - - it('onScroll / onScrollEnd receive a neutral ScrollPayload from currentTarget geometry', () => { - const onScroll = vi.fn() - const out = normalize({ onScroll }) - ;(out.onscroll as (e: unknown) => void)({ - currentTarget: { - scrollLeft: 5, - scrollTop: 50, - scrollWidth: 800, - scrollHeight: 1200, - clientWidth: 400, - clientHeight: 600, - }, - }) - expect(onScroll).toHaveBeenCalledWith({ - offsetX: 5, - offsetY: 50, - contentWidth: 800, - contentHeight: 1200, - viewportWidth: 400, - viewportHeight: 600, - }) + expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: 'hi' })) }) }) @@ -280,29 +220,6 @@ describe('svelte normalize — expanded attribute surface', () => { 'aria-atomic': true, }) }) - - it('translates a realistic slider binding set', () => { - const onValueChange = vi.fn() - const out = normalize({ - role: 'slider', - orientation: 'horizontal', - valueMin: 0, - valueMax: 100, - valueNow: 40, - valueText: '40%', - focusable: true, - onValueChange, - }) - expect(out).toMatchObject({ - role: 'slider', - 'aria-orientation': 'horizontal', - 'aria-valuemin': 0, - 'aria-valuemax': 100, - 'aria-valuenow': 40, - 'aria-valuetext': '40%', - tabindex: 0, - }) - ;(out.oninput as (e: unknown) => void)({ target: { value: '50', type: 'range' } }) - expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: '50' })) - }) }) + +describeVocabularyAccounting('svelte', normalize, { map: HANDLER_MAP }, { map: ATTR_MAP }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf347c..bd51320 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,12 @@ importers: '@dunky.dev/state-machine': specifier: workspace:* version: link:../core + '@dunky.dev/state-machine-bindings': + specifier: workspace:* + version: link:../shared/bindings + '@dunky.dev/state-machine-dom': + specifier: workspace:* + version: link:../dom devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^7.3.1 From 67a6958f375f30d64804c3d861d205e12d8f7677 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 4 Oct 2026 22:46:21 +0200 Subject: [PATCH 09/32] fix(svelte): build mergeProps on the shared base and composeHandlers mergeProps reimplemented the handler composition and dropped the base's camelCase composition. It now runs the agnostic mergeProps and composes Svelte's lowercase event props (onclick, onkeydown) with the shared composeHandlers, so the defaultPrevented veto is written once. Extending the composition on the Svelte side keeps the published base untouched for every other target. class merges as [consumer, library] whatever its shape: Svelte 5.16+ resolves strings, arrays, and objects through clsx, so the consumer's array or object class is no longer dropped. Overlapping style strings join with the library last, so its declarations win. Typed like the react and solid mergeProps, so Svelte's element prop types pass in and out cast-free. Co-Authored-By: Claude Opus 5.5 --- packages/svelte/package.json | 3 +- packages/svelte/src/merge-props.ts | 91 ++++++------------- packages/svelte/tests/merge-props.dom.test.ts | 15 +++ packages/svelte/tests/merge-props.test.ts | 90 +++++++++--------- pnpm-lock.yaml | 3 + 5 files changed, 95 insertions(+), 107 deletions(-) create mode 100644 packages/svelte/tests/merge-props.dom.test.ts diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 7bd45f4..31599e6 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -25,7 +25,8 @@ "dependencies": { "@dunky.dev/state-machine": "workspace:*", "@dunky.dev/state-machine-bindings": "workspace:*", - "@dunky.dev/state-machine-dom": "workspace:*" + "@dunky.dev/state-machine-dom": "workspace:*", + "@dunky.dev/state-machine-utils": "workspace:*" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.3.1", diff --git a/packages/svelte/src/merge-props.ts b/packages/svelte/src/merge-props.ts index dd6b73a..376937a 100644 --- a/packages/svelte/src/merge-props.ts +++ b/packages/svelte/src/merge-props.ts @@ -1,71 +1,40 @@ +import { composeHandlers, mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' + type AnyProps = Record 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) - } -} +// The agnostic base composes only `on` + an uppercase letter; Svelte's event +// props are the lowercase DOM names (`onclick`), so those compose here. +const isLowercaseHandlerKey = (key: string): boolean => /^on[a-z]/.test(key) /** - * 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. + * Merge consumer props with the component's normalized props, Svelte-style: + * the substrate-agnostic mergeProps (handlers compose, library wins) over + * Svelte's lowercase event props too, plus `class` of any shape merged as + * `[consumer, library]` (Svelte resolves strings, arrays, and objects through + * clsx) and overlapping `style` strings joined, library last so it wins. */ -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 +export function mergeProps( + consumer: Props | undefined, + library: AnyProps, +): Props & AnyProps { + const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library) + if (!consumer) return merged as Props & AnyProps + const own = consumer as AnyProps + + for (const key in library) { + const ours = own[key] + const theirs = library[key] + if (isLowercaseHandlerKey(key) && typeof ours === 'function' && typeof theirs === 'function') { + merged[key] = composeHandlers(ours as AnyHandler, theirs as AnyHandler) } - - 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 + } + if (own.class != null && library.class != null) { + merged.class = [own.class, library.class] + } + if (typeof own.style === 'string' && typeof library.style === 'string') { + merged.style = `${own.style}; ${library.style}` } - return out + return merged as Props & AnyProps } diff --git a/packages/svelte/tests/merge-props.dom.test.ts b/packages/svelte/tests/merge-props.dom.test.ts new file mode 100644 index 0000000..3c86020 --- /dev/null +++ b/packages/svelte/tests/merge-props.dom.test.ts @@ -0,0 +1,15 @@ +// @vitest-environment jsdom +/** + * Svelte mergeProps on a real element: the `[consumer, library]` class merge + * leans on Svelte resolving any class shape through clsx (5.16+). + */ +import { render } from '@testing-library/svelte' +import { expect, it } from 'vitest' +import { mergeProps } from '@dunky.dev/svelte-state-machine' +import Spread from './fixtures/spread.svelte' + +it('renders a merged class of mixed shapes as one class list', () => { + const attrs = mergeProps({ class: ['a', { b: true, off: false }] }, { class: 'c' }) + const { getByTestId } = render(Spread, { attrs }) + expect(getByTestId('target').className).toBe('a b c') +}) diff --git a/packages/svelte/tests/merge-props.test.ts b/packages/svelte/tests/merge-props.test.ts index c3c24f2..38bb751 100644 --- a/packages/svelte/tests/merge-props.test.ts +++ b/packages/svelte/tests/merge-props.test.ts @@ -1,72 +1,63 @@ -import { describe, expect, it, vi } from 'vitest' +/** + * Svelte mergeProps — consumer + component props, Svelte-style. Inherits + * handler composition (with the defaultPrevented veto) and library-wins from + * the agnostic base, extends the composition to Svelte's lowercase event + * props, and layers Svelte's attribute conventions: `class` of any shape + * merged as `[consumer, library]`, `style` strings joined. + */ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import type { HTMLButtonAttributes } from 'svelte/elements' import { mergeProps } from '@dunky.dev/svelte-state-machine' -describe('mergeProps', () => { - it('chains overlapping handlers — consumer first, library second', () => { - const calls: string[] = [] - const consumer = vi.fn(() => calls.push('consumer')) - const library = vi.fn(() => calls.push('library')) - const merged = mergeProps({ onclick: consumer }, { onclick: library }) +describe('svelte mergeProps', () => { + it('composes overlapping lowercase handlers — consumer then library', () => { + const order: string[] = [] + const merged = mergeProps( + { onclick: () => order.push('consumer') }, + { onclick: () => order.push('library') }, + ) ;(merged.onclick as (e: unknown) => void)({ defaultPrevented: false }) - expect(calls).toEqual(['consumer', 'library']) + expect(order).toEqual(['consumer', 'library']) }) - it('skips the library handler when the consumer prevents default', () => { + it('skips the library handler when the consumer prevents default (veto)', () => { const consumer = vi.fn() const library = vi.fn() - const merged = mergeProps({ onclick: consumer }, { onclick: library }) - ;(merged.onclick as (e: unknown) => void)({ defaultPrevented: true }) + const merged = mergeProps({ onkeydown: consumer }, { onkeydown: library }) + ;(merged.onkeydown as (e: unknown) => void)({ defaultPrevented: true }) expect(consumer).toHaveBeenCalledOnce() expect(library).not.toHaveBeenCalled() }) - it('only chains lowercase on* props (Svelte event shape), not camelCase', () => { + it('inherits camelCase handler composition from the agnostic base', () => { const consumer = vi.fn() const library = vi.fn() - // camelCase isn't a Svelte DOM event prop → library wins, no chaining. const merged = mergeProps({ onClick: consumer }, { onClick: library }) - expect(merged.onClick).toBe(library) + ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: false }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).toHaveBeenCalledOnce() }) - it('library wins on plain attrs', () => { + it('inherits library-wins on plain attrs', () => { const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) expect(out.id).toBe('lib') }) - it('concatenates overlapping styles as a string (consumer first, library second)', () => { - const out = mergeProps({ style: 'color: red' }, { style: 'background: blue' }) - expect(out.style).toBe('color: red; background: blue') - }) - - it('drops a trailing semicolon on the consumer style before joining', () => { - const out = mergeProps({ style: 'color: red;' }, { style: 'background: blue' }) - expect(out.style).toBe('color: red; background: blue') - }) - - it('library style wins when consumer omits style', () => { - const out = mergeProps({ id: 'a' }, { style: 'color: blue' }) - expect(out.style).toBe('color: blue') - }) - - it('consumer style stays when library omits style', () => { - const out = mergeProps({ style: 'color: red' }, { id: 'a' }) - expect(out.style).toBe('color: red') - }) - - it('concatenates overlapping class with a single space', () => { - const out = mergeProps({ class: 'a b' }, { class: 'c' }) - expect(out.class).toBe('a b c') + it('merges overlapping class of any shape as [consumer, library]', () => { + const consumerClass = ['a', { b: true }] + expect(mergeProps({ class: consumerClass }, { class: 'c' }).class).toEqual([consumerClass, 'c']) + expect(mergeProps({ class: 'a' }, { class: { c: true } }).class).toEqual(['a', { c: true }]) }) - it('trims edge whitespace on class; inner spacing is preserved verbatim', () => { - const out = mergeProps({ class: ' a ' }, { class: ' b ' }) - // `${' a '} ${' b '}` → ' a b ' → trim → 'a b' - expect(out.class).toBe('a b') + it('keeps a one-sided class untouched', () => { + const consumerClass = { a: true } + expect(mergeProps({ class: consumerClass }, { id: 'x' }).class).toBe(consumerClass) + expect(mergeProps({ id: 'x' }, { class: 'lib' }).class).toBe('lib') }) - it('non-string class falls back to library-wins (no concat)', () => { - const out = mergeProps({ id: 'a' }, { class: 'x' }) - expect(out.class).toBe('x') + it('joins overlapping style strings, library declarations last so they win', () => { + const out = mergeProps({ style: 'color: red' }, { style: 'color: blue' }) + expect(out.style).toBe('color: red; color: blue') }) it('returns the library props as-is when the consumer passes none', () => { @@ -74,3 +65,12 @@ describe('mergeProps', () => { expect(mergeProps(undefined, library)).toBe(library) }) }) + +describe('svelte mergeProps typing', () => { + it("accepts Svelte's element prop types and preserves them", () => { + const consumer: HTMLButtonAttributes = { class: ['mine', { active: true }], onclick: () => {} } + const out = mergeProps(consumer, { role: 'button' }) + expectTypeOf(out).toExtend() + expect(out.role).toBe('button') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd51320..02fe59d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: '@dunky.dev/state-machine-dom': specifier: workspace:* version: link:../dom + '@dunky.dev/state-machine-utils': + specifier: workspace:* + version: link:../shared/utils devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^7.3.1 From ff62e90c14a323a5b69cf946798ea23b79571ed8 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 4 Oct 2026 22:57:22 +0200 Subject: [PATCH 10/32] fix(svelte): make useMachine and useSelector track props and the machine faithfully useMachine: - the documented `() => props` usage never reached the snapshot: the connector stored the live props proxy and the setProps effect read no keys, so it never re-ran and Object.is short-circuited anyway. It now seeds a plain copy and spreads the props inside the effect, so every key is tracked and setProps value-dedups. - the api is the connector's own memoized object ($state.raw), not a deep-proxied copy with a new identity. - the connector subscription is a pre-effect: it runs synchronously at init (never on the server), so a send from a child's mount effect - which runs before the parent's - reaches the api instead of leaving it stale. - a ComponentEffect re-runs only when a named dep's value changes: one $derived per dep dedups it whatever the props getter reads (including getters that build their own object), and the body runs untracked so a prop it merely reads is never a hidden dependency. - lifecycle matches React's measured order: start, then the component effects, after mount; stop, then their cleanups, on destroy. Effects never run under svelte/server, pinned by an SSR suite. useSelector: - the selected value is held raw, never proxied, with the same init-time subscription; the props its selector closes over re-seed the value and the dedup baseline, and notify-time evaluations run untracked so a send inside an effect adds no dependencies to it. Co-Authored-By: Claude Opus 5.5 --- packages/svelte/src/use-machine.svelte.ts | 151 ++++------- packages/svelte/src/use-selector.svelte.ts | 67 ++--- packages/svelte/tests/fixtures/counters.ts | 20 +- .../tests/fixtures/selector-harness.svelte | 36 --- packages/svelte/tests/fixtures/sender.svelte | 7 + .../tests/fixtures/toggle-harness.svelte | 61 ----- packages/svelte/tests/fixtures/toggle.ts | 53 ++-- .../svelte/tests/fixtures/use-machine.svelte | 47 ++++ .../svelte/tests/fixtures/use-selector.svelte | 36 +++ packages/svelte/tests/ssr.test.ts | 48 ++++ packages/svelte/tests/use-machine.test.ts | 246 ++++++++++++------ packages/svelte/tests/use-selector.test.ts | 130 +++++---- packages/svelte/vitest.config.ts | 2 +- 13 files changed, 501 insertions(+), 403 deletions(-) delete mode 100644 packages/svelte/tests/fixtures/selector-harness.svelte create mode 100644 packages/svelte/tests/fixtures/sender.svelte delete mode 100644 packages/svelte/tests/fixtures/toggle-harness.svelte create mode 100644 packages/svelte/tests/fixtures/use-machine.svelte create mode 100644 packages/svelte/tests/fixtures/use-selector.svelte create mode 100644 packages/svelte/tests/ssr.test.ts diff --git a/packages/svelte/src/use-machine.svelte.ts b/packages/svelte/src/use-machine.svelte.ts index b11b2d6..77519a9 100644 --- a/packages/svelte/src/use-machine.svelte.ts +++ b/packages/svelte/src/use-machine.svelte.ts @@ -1,137 +1,82 @@ -/// +import { untrack } from 'svelte' import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' /** - * One substrate-specific effect, declared as a plain setup/teardown function - * plus the prop names it depends on: - * - * const escape: ComponentEffect = [ - * (machine, props) => { ...addEventListener...; return () => ...remove... }, - * ['closeOnEscape', 'onEscapeKeyDown'], // re-run when these props change - * ] - * - * The author writes no Svelte. The deps are prop NAMES (typed `(keyof Props)[]`, - * so typos are compile errors); the bridge turns them into a tracked `$effect` - * so the effect re-subscribes only when one of those props actually changes — - * not on every change, never stale. `machine` is always an implicit dep. - * - * Identical in shape to the React `ComponentEffect` — what changes is only how - * `useMachine` runs it (a Svelte `$effect`, not a React `useEffect`). + * One substrate-specific effect: a setup/teardown function plus the prop names + * that re-run it. The tuple shape is identical across every target, so a + * component's effects are authored once; `deps` are prop names (typed, so + * typos are compile errors) — the authored list is the whole re-run contract. */ export type ComponentEffect = [ effect: (machine: Machine, props: Props) => (() => void) | void, deps: (keyof Props)[], ] -type Service< - State extends string, - Context extends object, - Event extends { type: string }, - Computed, -> = ReturnType> - /** - * The one generic Svelte bridge. Every component's generated api calls this with - * the agnostic pieces — a config factory and the connect — plus the component's - * substrate effects and a GETTER for the resolved props: - * - * const view = useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, () => props) - * // then in markup: diff --git a/packages/svelte/tests/fixtures/toggle.ts b/packages/svelte/tests/fixtures/toggle.ts index 7459b17..766e695 100644 --- a/packages/svelte/tests/fixtures/toggle.ts +++ b/packages/svelte/tests/fixtures/toggle.ts @@ -1,23 +1,33 @@ -import { act, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + act as write, + makeReaction, + type Connect, + type machine, + type TransitionConfig, +} from '@dunky.dev/state-machine' -/** - * A tiny toggle machine used by the useMachine tests — the Svelte counterpart of - * the fixture the React bridge tests use. `open` flips state and counts toggles; - * `label` is a pass-through prop that proves prop changes flow through `setProps` - * (and don't rebuild the machine). - */ +// The toggle the useMachine suites (DOM and server) drive: `toggle` flips the +// state and counts opens; `label` passes through to prove props reach connect. +type ToggleState = 'closed' | 'open' +type ToggleEvent = { type: 'toggle' } +interface ToggleContext { + count: number +} + +export interface ToggleProps { + label?: string + onOpenChange?: (open: boolean) => void +} -export type ToggleState = 'closed' | 'open' -export type ToggleContext = { count: number } -export type ToggleEvent = { type: 'toggle' } -export type ToggleProps = { label?: string } -export type ToggleApi = { +export interface ToggleApi { open: boolean - count: number label: string | undefined + count: number toggle: () => void } +export type ToggleMachine = ReturnType> + export const createToggleConfig = (): TransitionConfig< ToggleState, ToggleContext, @@ -26,13 +36,10 @@ export const createToggleConfig = (): TransitionConfig< initial: 'closed', context: { count: 0 }, states: { - // The count write goes through `act` (setContext) so subscribers wake. closed: { - on: { toggle: { target: 'open', actions: act($ => ({ count: $.context.count + 1 })) } }, - }, - open: { - on: { toggle: { target: 'closed', actions: act($ => ({ count: $.context.count + 1 })) } }, + on: { toggle: { target: 'open', actions: write($ => ({ count: $.context.count + 1 })) } }, }, + open: { on: { toggle: { target: 'closed' } } }, }, }) @@ -44,7 +51,15 @@ export const connectToggle: Connect< ToggleApi > = ({ state, context, props, send }) => ({ open: state === 'open', - count: context.count, label: props.label, + count: context.count, toggle: () => send({ type: 'toggle' }), }) + +const reaction = makeReaction() +connectToggle.reactions = [ + reaction( + m => m.state === 'open', + (open, props) => props.onOpenChange?.(open), + ), +] diff --git a/packages/svelte/tests/fixtures/use-machine.svelte b/packages/svelte/tests/fixtures/use-machine.svelte new file mode 100644 index 0000000..272ec5a --- /dev/null +++ b/packages/svelte/tests/fixtures/use-machine.svelte @@ -0,0 +1,47 @@ + + + +{#if sendOnMount} + +{/if} diff --git a/packages/svelte/tests/fixtures/use-selector.svelte b/packages/svelte/tests/fixtures/use-selector.svelte new file mode 100644 index 0000000..927d370 --- /dev/null +++ b/packages/svelte/tests/fixtures/use-selector.svelte @@ -0,0 +1,36 @@ + + +{String(selection.current)} +{#if sendOnMount} + +{/if} diff --git a/packages/svelte/tests/ssr.test.ts b/packages/svelte/tests/ssr.test.ts new file mode 100644 index 0000000..71084bf --- /dev/null +++ b/packages/svelte/tests/ssr.test.ts @@ -0,0 +1,48 @@ +/** + * Server rendering (`svelte/server`): `$effect`s never run there, so a + * component renders its initial snapshot without starting the machine or + * running its effects, and spread bindings serialize as the client writes them. + */ +import { render } from 'svelte/server' +import { describe, expect, it, vi } from 'vitest' +import { mergeProps, normalize } from '@dunky.dev/svelte-state-machine' +import Spread from './fixtures/spread.svelte' +import UseMachine from './fixtures/use-machine.svelte' +import type { ToggleMachine } from './fixtures/toggle' + +describe('svelte — server rendering', () => { + it('renders the initial snapshot without starting the machine or running effects', () => { + const effect = vi.fn() + let machine: ToggleMachine | undefined + const { body } = render(UseMachine, { + props: { + label: 'ssr', + effects: [[effect, []]], + expose: (view: { machine: ToggleMachine }) => (machine = view.machine), + }, + }) + + const started = vi.fn() + machine!.onStart(started) // fires immediately on a running machine + expect(started).not.toHaveBeenCalled() + expect(effect).not.toHaveBeenCalled() + expect(body).toContain('ssr closed 0') + }) + + it('serializes spread bindings like the client: ARIA tokens, tabindex, merged class, no handlers', () => { + const attrs = mergeProps( + { class: ['a', { b: true }] }, + normalize({ expanded: false, hidden: true, focusable: false, onPress: () => {}, class: 'c' }), + ) + const { body } = render(Spread, { props: { attrs } }) + for (const attr of [ + 'aria-expanded="false"', + 'aria-hidden="true"', + 'tabindex="-1"', + 'class="a b c"', + ]) { + expect(body).toContain(attr) + } + expect(body).not.toContain('onclick') + }) +}) diff --git a/packages/svelte/tests/use-machine.test.ts b/packages/svelte/tests/use-machine.test.ts index 9678b64..fceab42 100644 --- a/packages/svelte/tests/use-machine.test.ts +++ b/packages/svelte/tests/use-machine.test.ts @@ -1,106 +1,182 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' -import { render, fireEvent } from '@testing-library/svelte' -import { tick } from 'svelte' -import ToggleHarness from './fixtures/toggle-harness.svelte' - -type Sink = { - api?: import('./fixtures/toggle').ToggleApi - renders: number - effectRuns: number - effectCleanups: number -} +// `useMachine` behavioral contract: build once, machine lifecycle, props +// freshness, reactions, dep-tracked ComponentEffects, and the reactive api. +// +// testing-library's rerender swaps one props object, so every prop read +// shares a signal — the way props arrive through a parent's `{...spread}`. +import { fireEvent, render } from '@testing-library/svelte' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ComponentEffect } from '@dunky.dev/svelte-state-machine' +import UseMachine from './fixtures/use-machine.svelte' +import { + connectToggle, + type ToggleApi, + type ToggleMachine, + type ToggleProps, +} from './fixtures/toggle' + +type View = { readonly api: ToggleApi; readonly machine: ToggleMachine } +type Effect = ComponentEffect -const newSink = (): Sink => ({ renders: 0, effectRuns: 0, effectCleanups: 0 }) +// Mounts the harness and hands back the live view alongside the render result. +function mount(props: Record = {}) { + let view: View | undefined + const result = render(UseMachine, { ...props, expose: (v: View) => (view = v) }) + return { ...result, view: view! } +} -describe('useMachine', () => { - it('exposes the connect() api and drives the markup', async () => { - const sink = newSink() - const { getByTestId } = render(ToggleHarness, { props: { label: 'a', sink } }) - await tick() +afterEach(() => vi.clearAllMocks()) - const btn = getByTestId('toggle') - expect(btn.textContent).toContain('a') - expect(btn.textContent).toContain('closed') - expect(sink.api?.open).toBe(false) +describe('useMachine — lifecycle', () => { + it('returns { api, machine }: api is the connect() output, machine is the running service', () => { + const { view } = mount({ label: 'hi' }) + expect(view.api).toMatchObject({ open: false, label: 'hi', count: 0 }) + expect(typeof view.api.toggle).toBe('function') + expect(typeof view.machine.send).toBe('function') }) - it('builds the machine ONCE: state survives prop changes', async () => { - const sink = newSink() - const { getByTestId, rerender } = render(ToggleHarness, { props: { label: 'a', sink } }) - await tick() - - await fireEvent.click(getByTestId('toggle')) // → open, count 1 - await tick() - expect(sink.api?.open).toBe(true) - expect(sink.api?.count).toBe(1) - - // A prop change must NOT rebuild the machine — state + count persist, and the - // fresh label flows through setProps. - await rerender({ label: 'b', sink }) - await tick() - expect(sink.api?.open).toBe(true) - expect(sink.api?.count).toBe(1) - expect(sink.api?.label).toBe('b') + it('renders from the snapshot and updates the DOM on a machine change', async () => { + const { getByTestId } = mount({ label: 'a' }) + expect(getByTestId('toggle').textContent).toBe('a closed 0') + await fireEvent.click(getByTestId('toggle')) + expect(getByTestId('toggle').textContent).toBe('a open 1') }) - it('produces one new snapshot per real machine change', async () => { - const sink = newSink() - const { getByTestId } = render(ToggleHarness, { props: { label: 'a', sink } }) - await tick() - const baseline = sink.renders - const firstApi = sink.api + it('fires the connect reaction (onOpenChange) while mounted, and not after unmount', () => { + const onOpenChange = vi.fn() + const { view, unmount } = mount({ onOpenChange }) + expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe + view.api.toggle() + expect(onOpenChange).toHaveBeenCalledWith(true) - // Each toggle is a real state change → exactly one new snapshot (the - // connector memoizes, so the identity changes only on a real change). - await fireEvent.click(getByTestId('toggle')) - await tick() - expect(sink.renders).toBe(baseline + 1) - expect(sink.api).not.toBe(firstApi) + // Unmount stops the machine, which unhooks the connector's reactions — a + // send may still transition, but the callback must not fire. + unmount() + view.machine.send({ type: 'toggle' }) + expect(onOpenChange).toHaveBeenCalledTimes(1) }) - it('runs a component effect on mount and cleans it up on unmount', async () => { - const sink = newSink() - const { unmount } = render(ToggleHarness, { - props: { label: 'a', sink, trackLabelEffect: true }, + it("starts after mount, before the component effects; stops before their cleanups (React's order)", () => { + const order: string[] = [] + const mounted = () => + document.querySelector('[data-testid="toggle"]') ? 'mounted' : 'unmounted' + const effect: Effect = [ + () => { + order.push(`effect:${mounted()}`) + return () => order.push('cleanup') + }, + [], + ] + const { unmount } = render(UseMachine, { + effects: [effect], + expose: (view: View) => { + view.machine.onStart(() => order.push(`start:${mounted()}`)) + view.machine.onStop(() => order.push('stop')) + }, }) - await tick() - expect(sink.effectRuns).toBe(1) - expect(sink.effectCleanups).toBe(0) + expect(order).toEqual(['start:mounted', 'effect:mounted']) + unmount() + expect(order).toEqual(['start:mounted', 'effect:mounted', 'stop', 'cleanup']) + }) +}) + +describe('useMachine — the api snapshot', () => { + it("is connect()'s own output, not a reactive copy", () => { + const outputs: ToggleApi[] = [] + const connect: typeof connectToggle = Object.assign( + (snapshot: Parameters[0]) => { + const api = connectToggle(snapshot) + outputs.push(api) + return api + }, + { reactions: connectToggle.reactions }, + ) + const { view } = mount({ connect }) + view.api.toggle() + expect(view.api).toBe(outputs.at(-1)) + }) + + it('reflects a send made before mount (a child mount effect runs first)', () => { + const { view, getByTestId } = mount({ sendOnMount: { type: 'toggle' } }) + expect(view.api.open).toBe(true) + expect(getByTestId('toggle').textContent).toBe('- open 1') + }) +}) + +describe('useMachine — props', () => { + it('builds the machine ONCE: state survives prop changes, which reach the snapshot', async () => { + const { view, rerender, getByTestId } = mount({ label: 'a' }) + view.api.toggle() // → open, count 1 + await rerender({ label: 'b' }) // must NOT rebuild/reset state + expect(view.api).toMatchObject({ open: true, count: 1, label: 'b' }) + expect(getByTestId('toggle').textContent).toBe('b open 1') + }) + it('value-dedups: a re-render with equal props does not churn the snapshot', async () => { + const { view, rerender } = mount({ label: 'x' }) + const before = view.api + await rerender({ label: 'x' }) // fresh props object, equal values + expect(view.api).toBe(before) + }) +}) + +describe('useMachine — component effects', () => { + it('runs each ComponentEffect: setup on mount, cleanup on unmount', () => { + const setup = vi.fn() + const cleanup = vi.fn() + const { unmount } = mount({ effects: [[() => (setup(), cleanup), []]] }) + expect(setup).toHaveBeenCalledOnce() + expect(cleanup).not.toHaveBeenCalled() unmount() - await tick() - expect(sink.effectCleanups).toBe(1) + expect(cleanup).toHaveBeenCalledOnce() }) - it('does not re-run a prop-scoped effect on an unrelated machine change', async () => { - const sink = newSink() - const { getByTestId } = render(ToggleHarness, { - props: { label: 'a', sink, trackLabelEffect: true }, - }) - await tick() - expect(sink.effectRuns).toBe(1) + // `copy`: the getter builds its own object (destructured defaults), so the + // deps must be tracked by value, not by which props the getter reads. + it.each([false, true])( + 're-runs an effect ONLY when one of its named prop deps changes (copied props: %s)', + async copy => { + const fn = vi.fn(() => () => {}) + const { rerender } = mount({ copy, label: 'a', effects: [[fn, ['label']]] }) + expect(fn).toHaveBeenCalledTimes(1) - // Toggling changes machine state, NOT the `label` prop the effect tracks — - // so the effect stays put. This is the precise-dependency guarantee: the - // effect reads only its named props, so runes wake it only for those. - await fireEvent.click(getByTestId('toggle')) - await tick() - expect(sink.effectRuns).toBe(1) + await rerender({ onOpenChange: () => {} }) // non-dep prop changed → no re-run + expect(fn).toHaveBeenCalledTimes(1) + + await rerender({ label: 'b' }) // dep changed → re-run + expect(fn).toHaveBeenCalledTimes(2) + }, + ) + + it('runs the previous cleanup BEFORE re-running on a dep change', async () => { + // A listener-registering effect must tear down before it sets up again, + // or every dep change stacks a listener. + const cleanup = vi.fn() + const fn = vi.fn(() => cleanup) + const { rerender } = mount({ label: 'a', effects: [[fn, ['label']]] }) + await rerender({ label: 'b' }) + expect(cleanup).toHaveBeenCalledOnce() + expect(cleanup.mock.invocationCallOrder[0]!).toBeLessThan(fn.mock.invocationCallOrder[1]!) }) - it('re-runs an effect when its named prop dep changes (cleaning up first)', async () => { - const sink = newSink() - const { rerender } = render(ToggleHarness, { - props: { label: 'a', sink, trackLabelEffect: true }, - }) - await tick() - expect(sink.effectRuns).toBe(1) - - // dep changed → re-run, and the prior setup is torn down first. - await rerender({ label: 'b', sink, trackLabelEffect: true }) - await tick() - expect(sink.effectRuns).toBe(2) - expect(sink.effectCleanups).toBe(1) + it('does NOT re-run when the effect body reads a prop outside its deps (untracked)', async () => { + // The authored deps list is the whole re-run contract — same as React's + // dep array. A prop the effect merely reads must not become a dependency. + const fn = vi.fn((_machine: ToggleMachine, props: ToggleProps) => void props.label) + const { rerender } = mount({ label: 'a', effects: [[fn, []]] }) + await rerender({ label: 'b' }) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('receives (machine, props) and can read live machine state', () => { + let seen: { open: boolean; label: string | undefined } | undefined + const effect: Effect = [ + (machine, props) => { + seen = { open: machine.matches('open'), label: props.label } + }, + [], + ] + mount({ label: 'hi', effects: [effect] }) + expect(seen).toEqual({ open: false, label: 'hi' }) }) }) diff --git a/packages/svelte/tests/use-selector.test.ts b/packages/svelte/tests/use-selector.test.ts index ebda0fd..48a49aa 100644 --- a/packages/svelte/tests/use-selector.test.ts +++ b/packages/svelte/tests/use-selector.test.ts @@ -1,66 +1,106 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' +// `useSelector` contract: a value-deduped `current` (Object.is by default, +// custom isEqual for objects) that follows the machine and the props its +// selector closes over, subscribed for the reader's lifetime. import { render } from '@testing-library/svelte' -import { tick } from 'svelte' -import SelectorHarness from './fixtures/selector-harness.svelte' -import { makeCounters } from './fixtures/counters' +import { flushSync } from 'svelte' +import { afterEach, describe, expect, it, vi } from 'vitest' +import UseSelector from './fixtures/use-selector.svelte' +import { makeCounters, type CountersMachine } from './fixtures/counters' + +// Sends outside an event handler; flush so the reader's effects have run. +const send = (m: CountersMachine, type: 'incA' | 'incB' | 'noop') => { + m.send({ type }) + flushSync() +} + +afterEach(() => vi.clearAllMocks()) describe('useSelector', () => { - it('reads the current selected value on mount', async () => { + it('reflects the selected value, updating when the machine changes it', () => { const m = makeCounters() - const sink = { value: undefined as unknown, updates: 0 } - const { getByTestId } = render(SelectorHarness, { - props: { machine: m, selector: () => m.context.a, sink }, + const { getByTestId } = render(UseSelector, { + machine: m, + pick: (m: CountersMachine) => m.context.a, }) - await tick() expect(getByTestId('value').textContent).toBe('0') - expect(sink.value).toBe(0) + send(m, 'incA') + expect(getByTestId('value').textContent).toBe('1') }) - it('updates ONLY when the selected slice changes', async () => { + it('wakes its reader ONLY when the selected value changes (Object.is by default)', () => { const m = makeCounters() - const sink = { value: undefined as unknown, updates: 0 } - render(SelectorHarness, { props: { machine: m, selector: () => m.context.a, sink } }) - await tick() - const baseline = sink.updates + const onread = vi.fn() + render(UseSelector, { machine: m, pick: (m: CountersMachine) => m.context.a > 0, onread }) + expect(onread).toHaveBeenCalledTimes(1) - // selects `a`; bumping `b` must NOT update this selection. - m.send({ type: 'incB' }) - await tick() - expect(sink.updates).toBe(baseline) - expect(sink.value).toBe(0) + send(m, 'incB') // another slice changed → no wake + send(m, 'noop') // nothing changed → no wake + expect(onread).toHaveBeenCalledTimes(1) - // bumping `a` updates it. - m.send({ type: 'incA' }) - await tick() - expect(sink.updates).toBe(baseline + 1) - expect(sink.value).toBe(1) + send(m, 'incA') // false → true → wake + expect(onread).toHaveBeenLastCalledWith(true) + send(m, 'incA') // re-derived, still true → no wake + expect(onread).toHaveBeenCalledTimes(2) }) - it('uses the provided isEqual to dedup an object selection', async () => { + it('dedups an object selection with isEqual, handing out the selected object itself', () => { const m = makeCounters() - const sink = { value: undefined as unknown, updates: 0 } - render(SelectorHarness, { - props: { - machine: m, - selector: () => ({ a: m.context.a }), - isEqual: (x: unknown, y: unknown) => (x as { a: number }).a === (y as { a: number }).a, - sink, + const selected: { a: number }[] = [] + const onread = vi.fn() + render(UseSelector, { + machine: m, + pick: (m: CountersMachine) => { + const value = { a: m.context.a } + selected.push(value) + return value }, + isEqual: (x: { a: number }, y: { a: number }) => x.a === y.a, + onread, + }) + + send(m, 'incB') // a fresh but equal { a } → no wake + expect(onread).toHaveBeenCalledTimes(1) + send(m, 'incA') // { a } changed → wake + expect(onread).toHaveBeenCalledTimes(2) + // Never a reactive copy: the reader sees the objects the selector returned. + for (const [value] of onread.mock.calls) expect(selected).toContain(value) + }) + + it('follows a prop the selector closes over, and compares later changes against it', async () => { + const m = makeCounters() + const { getByTestId, rerender } = render(UseSelector, { + machine: m, + pick: (m: CountersMachine, wanted: number) => m.context.a === wanted, + wanted: 0, }) - await tick() - const baseline = sink.updates + expect(getByTestId('value').textContent).toBe('true') + + await rerender({ wanted: 1 }) // a is 0 → false, with no machine change + expect(getByTestId('value').textContent).toBe('false') - // `b` changes, the selected `{ a }` is equal under isEqual → no update, - // despite the selector returning a fresh object each evaluation. - m.send({ type: 'incB' }) - await tick() - expect(sink.updates).toBe(baseline) + send(m, 'incA') // a is 1 → true against the NEW prop + expect(getByTestId('value').textContent).toBe('true') + }) + + it('reflects a send made before mount (a child mount effect runs first)', () => { + const m = makeCounters() + const { getByTestId } = render(UseSelector, { + machine: m, + pick: (m: CountersMachine) => m.context.a, + sendOnMount: { type: 'incA' }, + }) + expect(getByTestId('value').textContent).toBe('1') + }) + + it('stops evaluating the selector once unmounted', () => { + const m = makeCounters() + const pick = vi.fn((m: CountersMachine) => m.context.a) + const { unmount } = render(UseSelector, { machine: m, pick }) + unmount() - // `a` changes → the object differs under isEqual → one update. - m.send({ type: 'incA' }) - await tick() - expect(sink.updates).toBe(baseline + 1) - expect((sink.value as { a: number }).a).toBe(1) + const evaluations = pick.mock.calls.length + send(m, 'incA') // unmounted reader → the machine must not re-evaluate it + expect(pick.mock.calls.length).toBe(evaluations) }) }) diff --git a/packages/svelte/vitest.config.ts b/packages/svelte/vitest.config.ts index b272c59..695d86b 100644 --- a/packages/svelte/vitest.config.ts +++ b/packages/svelte/vitest.config.ts @@ -6,7 +6,7 @@ import { defineConfig } from 'vitest/config' // root workspace carries no Svelte dependencies. export default defineConfig({ // svelteTesting(): Svelte's client build under jsdom + cleanup between tests. - plugins: [svelte(), svelteTesting()], + plugins: [svelte({ configFile: false }), svelteTesting()], test: { name: 'svelte', globals: false, From c64ad206a243b9eab4da48333c047c5603be0e83 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 4 Oct 2026 23:07:41 +0200 Subject: [PATCH 11/32] build(svelte): ship dist through @sveltejs/package, typecheck .svelte with svelte-check The PR shipped raw TypeScript src behind a svelte export condition, so a consumer's tsc and svelte-check type-checked our sources under their own compiler options. tsdown can't take over: bundling folds the .svelte.ts runes modules into a plain index.js the Svelte compiler never sees, and a runes-free build has no public effect primitive that tracks props. @sveltejs/package 3 is the official convention: it strips the types, keeps each runes module as a .svelte.js file for the consumer's compiler, and emits a .d.ts per module. The published exports resolve through the types and svelte conditions only, so a tool that would load uncompiled runes fails loudly instead. The root build runs it after tsdown (so changeset:publish builds it too) and publint checks the packed manifest. Module specifiers are explicit .js: the output ships unbundled. state-machine-bindings becomes a dependency because the per-module declarations reference its types. svelte-package 3 and svelte-check 4.7 need the classic TypeScript JS API, which TypeScript 7 no longer ships, so the package pins a local typescript@^6. pnpm typecheck now runs svelte-check after tsc -b, which type-checks the .svelte test harnesses tsc can only see as a shim. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + package.json | 4 +- packages/svelte/package.json | 26 +++-- packages/svelte/src/index.ts | 8 +- .../svelte/tests/fixtures/use-selector.svelte | 8 +- packages/svelte/tests/use-selector.test.ts | 2 +- pnpm-lock.yaml | 108 +++++++++++++++++- tsdown.config.ts | 9 +- 8 files changed, 141 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index f107741..df63aad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist .DS_Store *.log .vite +.svelte-kit *.tsbuildinfo .expo diff --git a/package.json b/package.json index d835914..4dde817 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "benchmark:demo": "pnpm -C benchmark/demo dev", "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", - "build": "tsdown", - "typecheck": "tsc -b tsconfig.build.json", + "build": "tsdown && pnpm --filter @dunky.dev/svelte-state-machine build", + "typecheck": "tsc -b tsconfig.build.json && pnpm --filter @dunky.dev/svelte-state-machine typecheck", "lint": "oxlint .", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 31599e6..eeae515 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -8,19 +8,28 @@ "directory": "packages/svelte" }, "files": [ + "dist", "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" - } + ".": "./src/index.ts" + }, + "publishConfig": { + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "svelte-package -i src -o dist && publint", + "typecheck": "svelte-check --tsconfig ./tsconfig.json --output human --fail-on-warnings" }, "dependencies": { "@dunky.dev/state-machine": "workspace:*", @@ -29,10 +38,13 @@ "@dunky.dev/state-machine-utils": "workspace:*" }, "devDependencies": { + "@sveltejs/package": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@testing-library/svelte": "^5.4.2", "jsdom": "^29.1.1", "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "^6.0.3", "vite": "^8.3.2", "vitest": "^4.1.7" }, diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 7b90f21..7712f71 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -1,4 +1,4 @@ -export { useMachine, type ComponentEffect } from './use-machine.svelte' -export { useSelector } from './use-selector.svelte' -export { normalize, type Bindings } from './normalize' -export { mergeProps } from './merge-props' +export { useMachine, type ComponentEffect } from './use-machine.svelte.js' +export { useSelector } from './use-selector.svelte.js' +export { normalize, type Bindings } from './normalize.js' +export { mergeProps } from './merge-props.js' diff --git a/packages/svelte/tests/fixtures/use-selector.svelte b/packages/svelte/tests/fixtures/use-selector.svelte index 927d370..433de61 100644 --- a/packages/svelte/tests/fixtures/use-selector.svelte +++ b/packages/svelte/tests/fixtures/use-selector.svelte @@ -1,4 +1,4 @@ - diff --git a/sandbox/svelte/package.json b/sandbox/svelte/package.json index ddbd769..a580cdf 100644 --- a/sandbox/svelte/package.json +++ b/sandbox/svelte/package.json @@ -11,12 +11,14 @@ "dependencies": { "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", + "@dunky.dev/state-machine-dom": "workspace:^", + "@dunky.dev/state-machine-utils": "workspace:^", "@dunky.dev/svelte-state-machine": "workspace:^", "@sandbox/cmdk-core": "workspace:^", - "svelte": "^5.43.2" + "svelte": "^5.57.1" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "vite": "^8.0.14" + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "vite": "^8.3.2" } } diff --git a/sandbox/svelte/src/app.svelte b/sandbox/svelte/src/app.svelte index 99282c4..1edc5c1 100644 --- a/sandbox/svelte/src/app.svelte +++ b/sandbox/svelte/src/app.svelte @@ -4,55 +4,23 @@ let last = $state('—') - function onSelect(c: Command) { - last = c.label - window.alert(`Selected: ${c.label}`) + function onSelect(command: Command) { + last = command.label + window.alert(`Selected: ${command.label}`) } -
-

⌘K Command Pallete

+
+

⌘K Command Palette



-

+

One state machine drives this ⌘K palette.
- The same machine + connect runs the React, terminal (OpenTUI) and React Native versions. + The same machine + connect runs the Svelte, terminal (OpenTUI) and React Native versions +

+

+ Last selected: {last}

-

Last selected: {last}

- - diff --git a/sandbox/svelte/src/command-palette.svelte b/sandbox/svelte/src/command-palette.svelte index 4f67657..7f2596b 100644 --- a/sandbox/svelte/src/command-palette.svelte +++ b/sandbox/svelte/src/command-palette.svelte @@ -2,17 +2,15 @@ import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/svelte-state-machine' import { commandPaletteMachineConfig, - connectCommandPalette, type CommandPaletteMachine, type CommandPaletteProps, + connectCommandPalette, } from '@sandbox/cmdk-core' let props: CommandPaletteProps = $props() - // Global ⌘K / Ctrl+K to open — a PLATFORM listener (a document key event), so it - // lives here as a component effect, not in the machine. The machine just receives - // `open`. This is the per-target "behavior meets platform" seam — identical to the - // React sandbox's effect, only run through Svelte's useMachine instead of React's. + // Global ⌘K / Ctrl+K to open — a platform listener, so it lives here as a + // component effect, not in the machine. Same tuple shape as the React sandbox. const cmdkShortcut: ComponentEffect = [ machine => { const onKeyDown = (e: KeyboardEvent) => { @@ -27,9 +25,9 @@ [], ] - // The DOM renderer. It owns ZERO interaction logic — useMachine runs the shared - // machine, connect produces logical bindings, and normalize turns them into DOM - // props (onPress→onclick, role/aria-*, etc). The component is just markup. + // The DOM renderer — zero interaction logic; `useMachine` runs the shared + // machine and `normalize` maps the logical bindings to DOM props. The + // component is just markup; the look lives in the shared stylesheet. const view = useMachine( commandPaletteMachineConfig, connectCommandPalette, @@ -37,53 +35,46 @@ () => props, ) - let input = $state(null) + let input: HTMLInputElement | undefined = $state() - // Focus the input whenever the palette opens (a renderer concern, not the - // machine's — focus is a platform touchpoint). + // Focus on open — a platform touchpoint, so the renderer owns it. $effect(() => { if (view.api.open) input?.focus() })
- {#if view.api.open} -