From 1ac5a44856ae8c0c7f717435672217962c415cf8 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 23 Jun 2026 21:58:45 +0200 Subject: [PATCH 01/21] feat(solid): add @dunky.dev/state-machine-solid integration A first-class Solid bindings target (not a React re-export): useMachine mirrors the connector snapshot into a createStore via reconcile for fine-grained updates, runs the lifecycle through onMount/onCleanup, keeps props fresh with a tracked setProps effect, and runs each ComponentEffect as its own dep-tracked createEffect. useSelector returns a Solid accessor. normalize maps the agnostic bindings to Solid DOM props (onInput, onDblClick, tabindex) and mergeProps applies Solid's class concat + single-object style merge. Also split the tsconfig setup into a tsconfig/ folder (base/react/solid/all) so JSX is a per-project concern, since the repo now has both React and Solid JSX. Wires Solid into tsdown, the vitest solid project, docs, and a changeset. Co-authored-by: Claude --- .changeset/solid-integration.md | 15 ++ benchmark/tsconfig.json | 2 + package.json | 4 +- packages/solid/CHANGELOG.md | 1 + packages/solid/LICENSE | 21 ++ packages/solid/README.md | 205 +++++++++++++++ packages/solid/package.json | 47 ++++ packages/solid/src/index.ts | 4 + packages/solid/src/merge-props.ts | 35 +++ packages/solid/src/normalize.ts | 178 +++++++++++++ packages/solid/src/use-machine.ts | 140 +++++++++++ packages/solid/src/use-selector.ts | 56 +++++ packages/solid/tests/merge-props.test.ts | 69 ++++++ packages/solid/tests/normalize.test.ts | 266 ++++++++++++++++++++ packages/solid/tests/use-machine.test.tsx | 275 +++++++++++++++++++++ packages/solid/tests/use-selector.test.tsx | 138 +++++++++++ packages/solid/tsconfig.json | 9 + pnpm-lock.yaml | 172 ++++++++++++- tsconfig.all.json | 7 - tsconfig.json | 27 +- tsconfig/all.json | 8 + tsconfig/base.json | 26 ++ tsconfig/react.json | 8 + tsconfig/solid.json | 18 ++ tsdown.config.ts | 1 + vitest.config.ts | 39 ++- website/astro.config.ts | 1 + website/src/content/docs/libs/solid.mdx | 199 +++++++++++++++ 28 files changed, 1933 insertions(+), 38 deletions(-) create mode 100644 .changeset/solid-integration.md create mode 100644 packages/solid/CHANGELOG.md create mode 100644 packages/solid/LICENSE create mode 100644 packages/solid/README.md create mode 100644 packages/solid/package.json create mode 100644 packages/solid/src/index.ts create mode 100644 packages/solid/src/merge-props.ts create mode 100644 packages/solid/src/normalize.ts create mode 100644 packages/solid/src/use-machine.ts create mode 100644 packages/solid/src/use-selector.ts create mode 100644 packages/solid/tests/merge-props.test.ts create mode 100644 packages/solid/tests/normalize.test.ts create mode 100644 packages/solid/tests/use-machine.test.tsx create mode 100644 packages/solid/tests/use-selector.test.tsx create mode 100644 packages/solid/tsconfig.json delete mode 100644 tsconfig.all.json create mode 100644 tsconfig/all.json create mode 100644 tsconfig/base.json create mode 100644 tsconfig/react.json create mode 100644 tsconfig/solid.json create mode 100644 website/src/content/docs/libs/solid.mdx diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md new file mode 100644 index 0000000..fb5c0fb --- /dev/null +++ b/.changeset/solid-integration.md @@ -0,0 +1,15 @@ +--- +'@dunky.dev/state-machine-solid': minor +--- + +Add `@dunky.dev/state-machine-solid` — the Solid bindings target. + +A first-class Solid bridge (not a React re-export): `useMachine` mirrors the +connector's snapshot into a Solid `createStore` (via `reconcile`) so reading a +field in JSX is fine-grained, runs the lifecycle through `onMount`/`onCleanup`, +keeps props fresh with a tracked `setProps` effect, and runs each +`ComponentEffect` as its own dep-tracked `createEffect`. `useSelector` returns a +Solid accessor. `normalize` maps the agnostic bindings to Solid DOM props +(`onInput`, `onDblClick`, `tabindex`) and `mergeProps` applies Solid's `class` +concat + single-object `style` merge. The same `connect` and machine config run +unchanged across React, Solid, React Native, and OpenTUI. diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json index a602cb2..48a9a23 100644 --- a/benchmark/tsconfig.json +++ b/benchmark/tsconfig.json @@ -3,6 +3,8 @@ // don't typecheck), so this is the only thing that catches type errors here. "extends": "../tsconfig.json", "compilerOptions": { + // the base sets no `jsx` (it's per-project); the benchmark is React-flavored + "jsx": "react-jsx", // paths in `extends` resolve relative to THIS file, so redeclare them "paths": { "@dunky.dev/state-machine": ["../packages/core/src"], diff --git a/package.json b/package.json index 6fe0557..a51ec90 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", - "typecheck": "tsc -b tsconfig.all.json", + "typecheck": "tsc -b tsconfig/all.json", "lint": "oxlint .", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -30,8 +30,10 @@ "oxfmt": "^0.52.0", "oxlint": "^1.67.0", "publint": "^0.3.21", + "solid-js": "^1.9.13", "tsdown": "^0.22.2", "typescript": "^6.0.3", + "vite-plugin-solid": "^2.11.12", "vitest": "^4.1.7" }, "packageManager": "pnpm@10.20.0" diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md new file mode 100644 index 0000000..092b0ac --- /dev/null +++ b/packages/solid/CHANGELOG.md @@ -0,0 +1 @@ +# @dunky.dev/state-machine-solid diff --git a/packages/solid/LICENSE b/packages/solid/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/solid/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/solid/README.md b/packages/solid/README.md new file mode 100644 index 0000000..962fddf --- /dev/null +++ b/packages/solid/README.md @@ -0,0 +1,205 @@ +# `@dunky.dev/state-machine-solid` + +The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). The +core engine is renderer-agnostic; this package is the thin Solid edge that drives +it: it builds the machine + connector, runs the Solid lifecycle, mirrors the +connector's snapshot into a fine-grained store, translates the agnostic +[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM +props, and owns the per-component substrate effects. + +This is a **first-class Solid target**, not a re-export of the React bridge. +React's adapters re-export onto React Native and OpenTUI because those share a +React reconciler; Solid has its own fine-grained reactivity, so the lifecycle is +implemented with Solid primitives — `createStore` + `reconcile`, `createEffect`, +`onMount`/`onCleanup` — and there is no `useSyncExternalStore`. The behavior +still lives in the core machine and the component's `connect`; this layer only +adapts them to Solid. Four exports: `useMachine`, `useSelector`, `normalize`, +`mergeProps`, plus the `ComponentEffect` types. + +--- + +## `useMachine` — the one bridge hook + +Every component's generated `useXxxApi` calls this with the agnostic pieces: + +```ts +const { api, machine } = useMachine( + tooltipMachineConfig, // (props) => config — config factory, props seed it ONCE + connectTooltip, // pure connect(): snapshot → view api + tooltipEffects, // the component's substrate effects (ComponentEffect[]) + props, // the reactive Solid props +) +``` + +It: + +- **builds once** — `machine(createConfig(props))` + `connector(service, connect, +{ ...props })`. A Solid component body runs a single time, so a plain build IS + "build once" (no `useMemo` equivalent). The first props seed context and the + initial state; later prop changes flow through `setProps`, never a rebuild. + > The connector is seeded with a **plain snapshot** (`{ ...props }`), never the + > live Solid props proxy. The connector value-dedups in `setProps`; if it held + > the proxy it would later compare the proxy against a fresh spread of that same + > proxy — whose getters have already updated — find them equal, and never wake. +- **is fine-grained** — the connector's snapshot is mirrored into a + `createStore` via `reconcile` on every connector wake. Reading `api.isOpen` in + JSX subscribes to exactly that leaf, so an unrelated field changing won't touch + it. `api` is the store proxy — **don't destructure it** (`const { isOpen } = +api` snapshots the value and drops reactivity); read its fields where you use + them. +- **keeps props fresh** via a tracked effect — `createEffect(() => +connection.setProps({ ...props }))`. Solid auto-tracks every prop read in the + spread, so it re-runs whenever a consumed prop changes, with no manual dep + list. `setProps` value-dedups. +- **runs the lifecycle** — `service.start()` in `onMount`, `service.stop()` in + `onCleanup`. The connector wired its + [reactions](../core/README.md#reactions--firing-prop-callbacks-without-the-machine-knowing) + to the machine's own `start`/`stop`, so prop-callbacks follow automatically. +- **runs the component's substrate effects** — one `createEffect` per + `ComponentEffect` entry, each reading its named prop deps so it re-subscribes + only when one of them changes (see below). + +Returns `{ api, machine }`: `api` is the reactive store to spread onto elements; +`machine` is the running service (also handed to `useSelector`). + +--- + +## `ComponentEffect` — substrate transport, without the boilerplate + +Some behavior can't live in the agnostic machine because it needs the **platform +itself** — a DOM `keydown` listener for Escape, a `ResizeObserver` — and the +**props** the machine never sees (`closeOnEscape`). That's the component's +Solid-side _effect_. + +Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`) — the +**same shape as every other target**, so a component's effects are authored once +and run unchanged on React and Solid: + +```ts +import type { ComponentEffect } from '@dunky.dev/state-machine-solid' + +type TooltipEffect = ComponentEffect + +/** Escape-to-close (gated by closeOnEscape). */ +const trackEscape: TooltipEffect = [ + (machine, props) => { + if (!props.closeOnEscape) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') machine.send({ type: 'escape' }) + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, + ['closeOnEscape'], // ← re-run only when this prop changes +] + +export const tooltipEffects = [trackEscape] +``` + +`useMachine` runs the list — **one `createEffect` per entry**. Each effect READS +its declared prop deps, so Solid's auto-tracking re-runs it (cleanup → setup) +only when one of those props actually changes, never on unrelated changes. The +deps are prop NAMES — typed `(keyof Props)[]`, so a typo is a compile error. +Reading the deps explicitly (rather than letting the effect body's own reads +decide) keeps the dependency set driven by the authored `deps` and identical to +every other target. + +> The agnostic _decision_ lives in the core component's resolver; only the +> _transport_ (the DOM listener) is here. The machine just receives a plain event. + +--- + +## `useSelector` — fine-grained leaf subscription + +Returns a Solid **accessor** that updates only when one slice of the machine +changes: + +```ts +const open = useSelector(machine, () => machine.matches('open')) +const isHL = useSelector(machine, () => machine.context.highlightedValue === value) +// read it in JSX:
+``` + +Backed by a `createSignal` driven by the machine's `select` — a value-deduped +Selection. `Object.is` by default; **an object/array selection MUST pass a custom +`isEqual`** so a re-derived equal value doesn't push a change: + +```ts +const pos = useSelector( + machine, + () => ({ x: machine.context.x, y: machine.context.y }), + (a, b) => a.x === b.x && a.y === b.y, +) +``` + +`api` from `useMachine` is already fine-grained, so reach for `useSelector` when +a leaf wants to track one slice of a machine it doesn't otherwise own — e.g. +thousands of rows backed by one machine, each waking only for its own value +(`O(readers)`). + +--- + +## `normalize` — agnostic bindings → DOM props + +`connect` returns substrate-agnostic +[bindings](../core/README.md#connector--the-view-boundary) (`onPress`, `role`). +`normalize` translates them to DOM/ARIA props as Solid's JSX expects them: + +```ts +const domProps = normalize(api.triggerProps) // { onClick, 'aria-expanded', role, tabindex, ... } +``` + +Same vocabulary as the React DOM normalizer, with Solid's JSX conventions: + +| Agnostic binding | Solid DOM prop | +| ---------------- | ------------------------------------- | +| `onPress` | `onClick` | +| `onValueChange` | `onInput` (wrapped → `ChangePayload`) | +| `onDoublePress` | `onDblClick` | +| `focusable` | `tabindex` (`true → 0`, `false → -1`) | + +Pointer/keyboard/focus handlers and the full ARIA attribute set map exactly as in +the [React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props). +`undefined` values are dropped; any key not in the map (`class`, `data-*`) passes +through unchanged. `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` are wrapped +so the consumer receives the agnostic payload built from the native DOM event. + +--- + +## `mergeProps` — combine consumer props with the component's props + +When a consumer spreads their own props onto the same element the component +controls, `mergeProps(consumer, library)` merges them the Radix/Ark way, Solid +flavor: + +```ts +const finalProps = mergeProps(consumerProps, normalize(api.triggerProps)) +``` + +- **Event handlers are chained, consumer-first** — both run, but if the + consumer's handler marks the event `defaultPrevented`, the library handler is + skipped (a clean veto). +- **`class` is concatenated** with a single space and trimmed (Solid uses + `class`, not React's `className`). +- **`style` is merged into ONE object**, library winning on conflicting keys. + Solid's `style` is a plain object, not React's array form — so styles merge + rather than wrap. +- **Everything else: library wins** (`id`, `role`, `aria-*`). + +> This is **not** Solid's own `mergeProps` from `solid-js` (which merges reactive +> prop objects). It merges the consumer's props with the component's normalized +> bindings. + +--- + +## API + +| Export | What it is | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `useMachine(config, connect, effects, props)` | the bridge hook — build once + lifecycle + run effects + fine-grained store; returns `{ api, machine }` | +| `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice; returns a Solid accessor (`O(readers)`) | +| `normalize(bindings)` | agnostic bindings → Solid DOM/ARIA props | +| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `class` concat; `style` object merge) | +| `ComponentEffect` | `[ (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/solid/package.json b/packages/solid/package.json new file mode 100644 index 0000000..b711b2d --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,47 @@ +{ + "name": "@dunky.dev/state-machine-solid", + "version": "0.2.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/solid" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:^", + "@dunky.dev/state-machine-utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/testing-library": "^0.8.10", + "jsdom": "^29.1.1", + "solid-js": "^1.9.13" + }, + "peerDependencies": { + "solid-js": "^1.6.0" + } +} diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts new file mode 100644 index 0000000..d113d89 --- /dev/null +++ b/packages/solid/src/index.ts @@ -0,0 +1,4 @@ +export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine' +export { useSelector } from './use-selector' +export { normalize, type Bindings } from './normalize' +export { mergeProps } from './merge-props' diff --git a/packages/solid/src/merge-props.ts b/packages/solid/src/merge-props.ts new file mode 100644 index 0000000..93be260 --- /dev/null +++ b/packages/solid/src/merge-props.ts @@ -0,0 +1,35 @@ +import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' + +type AnyProps = Record + +/** + * Merge consumer props with the component's normalized props, Solid-style. + * + * Layers Solid's DOM conventions on the substrate-agnostic mergeProps (handler + * compose with the `defaultPrevented` veto; everything else library-wins): + * + * - `class` is concatenated with a space (Solid uses `class`, not React's + * `className`). + * - `style` is merged into ONE object, library winning on conflicting keys. + * Solid's `style` prop is a plain object (or string), NOT React's array form — + * so styles merge rather than wrap. (String styles fall through to + * library-wins; mixing a string and an object on the same element is a consumer + * error Solid itself wouldn't merge either.) + */ +export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { + const merged = baseMergeProps(consumer, library) + if (!consumer) return merged + + if (typeof consumer.class === 'string' && typeof library.class === 'string') { + merged.class = `${consumer.class} ${library.class}`.trim() + } + if (isStyleObject(consumer.style) && isStyleObject(library.style)) { + merged.style = { ...consumer.style, ...library.style } + } + + return merged +} + +function isStyleObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null +} diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts new file mode 100644 index 0000000..847d09a --- /dev/null +++ b/packages/solid/src/normalize.ts @@ -0,0 +1,178 @@ +/** + * Translate the machine layer's LOGICAL surface to Solid DOM props. + * + * Logical handler → DOM event prop + * Logical attr → DOM/ARIA attr + * + * Differences from the React DOM normalizer worth flagging: + * + * - Solid's JSX event props are NATIVE DOM events, not React's synthetic ones. + * `onClick` is fine (Solid delegates it), and the event handed to a handler is + * a real `MouseEvent`/`PointerEvent`/`KeyboardEvent`/`WheelEvent`, so the + * payload adapters below read the native event shape (same field names). + * - Solid uses lowercase `tabindex` (the real attribute), not React's camelCase + * `tabIndex`. That's the only attr name that differs from the DOM normalizer — + * the ARIA attributes (`aria-*`) are written verbatim in Solid JSX, exactly as + * here. + */ + +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 +// native DOM event. For those, normalize wraps the handler so the component +// receives the agnostic payload — built here from the DOM event — rather than +// the event itself. (onPress/pointer/keyboard handlers already receive a shape +// that overlaps PointerPayload/KeyboardPayload, so they pass through unwrapped.) + +// 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/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts new file mode 100644 index 0000000..b7adf99 --- /dev/null +++ b/packages/solid/src/use-machine.ts @@ -0,0 +1,140 @@ +import { createEffect, onCleanup, onMount } from 'solid-js' +import { createStore, reconcile } from 'solid-js/store' +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 Solid. The deps are prop NAMES (typed `(keyof Props)[]`, + * so typos are compile errors). The bridge runs each effect inside its own + * `createEffect` and READS those named props there, so Solid's auto-tracking + * re-runs the effect (cleanup → setup) only when one of those props actually + * changes — not on unrelated changes, never stale. `machine` is a constant, so + * it's not a dependency. + * + * The tuple shape is identical across every target (React, Solid, …) so a + * component's effects are authored ONCE and run unchanged everywhere; only how + * the bridge consumes the deps differs (a manual dep array on React, reactive + * reads here). + */ +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 `createEffect` so only the one whose dep changed re-subscribes. + * + * Unlike React there's no rules-of-hooks constraint here (a `createEffect` is + * not a hook), but keeping it a stable module constant (`export const xEffects = + * [...]`) is still the convention — it reads identically across targets and + * never rebuilds the effect closures per call. + */ +export type ComponentEffects = ComponentEffect[] + +/** + * The one generic Solid bridge. Every component's generated api.ts calls this + * with the agnostic pieces — a config factory and the connect — plus the + * component's substrate effects and the (reactive) props: + * + * useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, props) + * + * It builds the machine + connector ONCE (a Solid component body runs once, so + * no memo is needed — the first render's props seed context + the initial + * state), mirrors the connector's snapshot into a fine-grained `createStore` + * (via `reconcile`, so only the leaves that actually changed notify their JSX + * readers — the whole point of a Solid target), starts the machine on mount and + * stops it on cleanup (the connector's reactions follow the machine's lifecycle + * automatically), keeps props fresh via a tracked `setProps` effect, and runs + * the component's prop-dependent effects (Escape, etc — one `createEffect` each, + * re-running when their named prop deps change). + * + * Returns the connect() api (the reactive store proxy — read `api.isOpen` in + * JSX and it updates fine-grained) and the running machine. + * + * Later prop changes flow through `setProps`, never a rebuild — recreating would + * lose state. + */ +export function useMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Props extends object, + Api extends object, + Computed = Record, +>( + createConfig: (props: Props) => TransitionConfig, + connect: Connect, + effects: ComponentEffects>, Props>, + props: Props, +): { api: Api; machine: ReturnType> } { + // Build machine + connector once. A Solid component body runs a single time, + // so a plain build IS "build once" — no useMemo equivalent needed. The props + // proxy is reactive; reading it here at build time seeds context + the initial + // state from the first values. + // + // CRITICAL: seed the connector with a PLAIN snapshot (`{ ...props }`), never + // the live Solid props proxy. The connector value-dedups in setProps + // (shallowEqual), and if it held the proxy it would later compare the proxy + // against a fresh spread of that same proxy — whose values have already + // updated through the getters — find them equal, and never wake. A frozen + // plain copy makes "did a prop change?" a real comparison. + const service = machine(createConfig(props)) + const connection = connector(service, connect, { ...props }) + + // Mirror the connector's snapshot into a fine-grained store. `reconcile` + // deep-diffs the new snapshot against the store, so reading `api.isOpen` in + // JSX subscribes to exactly that leaf — an unrelated field changing won't + // touch it. This is what makes the Solid target fine-grained rather than a + // coarse "re-render the whole component" bridge. The connector already + // memoizes its snapshot (stable identity while clean), and `reconcile` is a + // no-op when nothing changed, so a wake that didn't move anything is cheap. + const [api, setApi] = createStore(connection.snapshot) + const off = connection.subscribe(() => setApi(reconcile(connection.snapshot))) + onCleanup(off) + + // Keep consumer props fresh (controlled flags, callbacks). `createEffect` + // tracks every prop read inside `connection.setProps(props)` — Solid props are + // a reactive proxy — so this re-runs whenever any consumed prop changes, with + // no manual dep list. setProps value-dedups, so an unchanged prop set is a + // no-op. (The connector was seeded with the initial props at build, so this + // only pushes subsequent changes.) + createEffect(() => connection.setProps({ ...props })) + + // Lifecycle: boot on mount, tear down on cleanup. The connector wired its + // reactions to the machine's own start/stop, so start()/stop() is all the + // bridge needs — reactions follow automatically. + // + // We deliberately do NOT call connection.destroy(): the connector shares this + // component's lifetime with the machine (both built above), so they're GC'd + // together. destroy() exists for callers that build a connector standalone. + onMount(() => service.start()) + onCleanup(() => service.stop()) + + // Component effects — the prop-dependent platform listeners (Escape, etc) the + // machine can't own. One `createEffect` per entry: it READS the named prop + // deps (so Solid re-runs it when one of them changes), runs the effect, and + // registers the returned teardown via onCleanup (run before the next re-run + // and on unmount). Reading the deps explicitly — rather than letting the + // effect body's own reads decide — keeps the dependency set identical to every + // other target, driven by the authored `deps` and nothing else. + for (const [fn, deps] of effects) { + createEffect(() => { + // Touch each declared dep so this effect re-runs when it changes. + for (const key of deps) void props[key] + const cleanup = fn(service, props) + if (cleanup) onCleanup(cleanup) + }) + } + + return { api, machine: service } +} diff --git a/packages/solid/src/use-selector.ts b/packages/solid/src/use-selector.ts new file mode 100644 index 0000000..5984fcc --- /dev/null +++ b/packages/solid/src/use-selector.ts @@ -0,0 +1,56 @@ +import { createSignal, onCleanup, type Accessor } from 'solid-js' +import type { EqualityFn, Machine } from '@dunky.dev/state-machine' + +/** + * Fine-grained, selector-based subscription for leaf components. + * + * The selector reads from the machine directly (`m.context.x`, `m.matches(...)`) + * and the returned accessor updates only when the selected VALUE changes — not + * on every machine change. + * + * Returns a Solid Accessor (`() => T`): call it in JSX (`{open()}`) and that JSX + * tracks it. The accessor is backed by a `createSignal` driven by the machine's + * `select` — a value-deduped Selection. Every machine notify re-evaluates the + * selector and value-compares the result (coarse bus + value compare, not + * field-level dependency tracking); the signal is written only when the selected + * value actually changed, so a change to an UNRELATED field never wakes this + * reader. For a leaf list backed by ONE machine per item (the common shape), + * each item's accessor wakes only for its own value — the O(readers) property + * that makes thousands of leaves cheap. + * + * const open = useSelector(m, () => m.matches('open')) + * const isHL = useSelector(m, () => m.context.highlightedValue === value) + * + * Equality is `Object.is` by default; pass a custom `isEqual` for object + * selections so a re-derived equal object doesn't push a new value. + * + * The Selection's own dedup AND the signal's equality both use `isEqual`, so an + * object selection that returns a fresh `{...}` each evaluation stays stable as + * long as `isEqual` deems it unchanged — no spurious updates. + */ +export function useSelector< + State extends string, + Context extends object, + T, + Event extends { type: string } = { type: string }, + Computed = Record, +>( + machine: Machine, + selector: () => T, + isEqual?: EqualityFn, +): Accessor { + const selection = machine.select(selector) + + // Seed the signal with the current selected value. The signal's own equality + // is the caller's `isEqual` (falling back to Solid's default Object.is), so + // writing an equal value is a no-op — a second line of dedup behind the + // Selection's bus-level one. + const [value, setValue] = createSignal(selection.value, { equals: isEqual }) + + // The Selection fires its listener only when the selected value changes + // (Object.is by default, or our `isEqual`); we push that into the signal. + const off = selection.subscribe(next => setValue(() => next), isEqual) + onCleanup(off) + + return value +} diff --git a/packages/solid/tests/merge-props.test.ts b/packages/solid/tests/merge-props.test.ts new file mode 100644 index 0000000..cec0e17 --- /dev/null +++ b/packages/solid/tests/merge-props.test.ts @@ -0,0 +1,69 @@ +/** + * Solid mergeProps — consumer + component props, Solid-style. Inherits handler + * composition (with the defaultPrevented veto) and library-wins from the agnostic + * base; layers Solid's DOM conventions on top: `class` concat (not `className`) + * and `style` merged into ONE object (Solid's style is an object, not React's + * array form). + */ +import { describe, expect, it, vi } from 'vitest' +import { mergeProps } from '../src' + +describe('solid mergeProps', () => { + it('inherits handler composition from the agnostic base', () => { + const consumer = vi.fn() + const library = vi.fn() + const merged = mergeProps({ onClick: consumer }, { onClick: library }) + ;(merged.onClick as (e: unknown) => void)({ defaultPrevented: false }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).toHaveBeenCalledOnce() + }) + + 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 }) + expect(consumer).toHaveBeenCalledOnce() + expect(library).not.toHaveBeenCalled() + }) + + it('inherits library-wins on plain attrs', () => { + const out = mergeProps({ id: 'consumer' }, { id: 'lib' }) + expect(out.id).toBe('lib') + }) + + it('merges overlapping styles into ONE object — library wins on conflicts', () => { + const out = mergeProps( + { style: { color: 'red', margin: 0 } }, + { style: { color: 'blue', padding: 4 } }, + ) + expect(out.style).toEqual({ color: 'blue', margin: 0, padding: 4 }) + }) + + it('library style wins when consumer omits style', () => { + const libStyle = { color: 'blue' } + const out = mergeProps({ id: 'a' }, { style: libStyle }) + expect(out.style).toBe(libStyle) + }) + + it('consumer style stays when library omits style', () => { + const consumerStyle = { color: 'red' } + const out = mergeProps({ style: consumerStyle }, { id: 'a' }) + expect(out.style).toBe(consumerStyle) + }) + + 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; inner spacing is preserved verbatim', () => { + const out = mergeProps({ class: ' a ' }, { class: ' 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') + }) +}) diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts new file mode 100644 index 0000000..c31de38 --- /dev/null +++ b/packages/solid/tests/normalize.test.ts @@ -0,0 +1,266 @@ +/** + * Solid DOM bindings translator — pure-logic tests (no DOM runtime needed). + * + * `normalize` maps the core's substrate-agnostic logical surface to real + * DOM/ARIA props as Solid's JSX expects them. 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: + * `onValueChange → onInput`, `onDoublePress → onDblClick`, `focusable → + * tabindex` (lowercase). + */ +import { describe, expect, it, vi } from 'vitest' +import { normalize } from '../src' + +describe('solid 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 DOM pointer events', () => { + const handlers = { + onPointerEnter: vi.fn(), + onPointerLeave: vi.fn(), + onPointerMove: vi.fn(), + onPointerDown: vi.fn(), + onPointerUp: vi.fn(), + onPointerCancel: vi.fn(), + } + expect(normalize(handlers)).toEqual(handlers) + }) + + it('passes onFocus / onBlur through', () => { + const onFocus = vi.fn() + const onBlur = vi.fn() + expect(normalize({ onFocus, onBlur })).toEqual({ onFocus, onBlur }) + }) + + it('maps both keyboard handlers (onKeyDown / onKeyUp)', () => { + const onKeyDown = vi.fn() + const onKeyUp = vi.fn() + expect(normalize({ onKeyDown, onKeyUp })).toEqual({ onKeyDown, onKeyUp }) + }) +}) + +describe('solid 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 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 (lowercase; 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, class)', () => { + expect(normalize({ 'data-state': 'open', class: 'x' })).toEqual({ + 'data-state': 'open', + class: 'x', + }) + }) + + it('skips undefined values', () => { + expect(normalize({ role: undefined, id: 'x' })).toEqual({ id: 'x' }) + }) +}) + +describe('solid normalize — expanded handler surface', () => { + it('maps each value-change / interaction handler to its Solid 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( + ['onContextMenu', 'onDblClick', 'onInput', '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('solid 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/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx new file mode 100644 index 0000000..8930f17 --- /dev/null +++ b/packages/solid/tests/use-machine.test.tsx @@ -0,0 +1,275 @@ +// @vitest-environment jsdom +/** + * `useMachine` — the Solid bridge. These tests pin the behavioral contract: build + * ONCE, run the machine lifecycle (start on mount / stop on cleanup), keep + * consumer props fresh via a tracked setProps effect (value-deduped), run the + * connector's reactions across the machine lifecycle, run each ComponentEffect as + * its own dep-tracked createEffect, and expose the connect() api as a fine-grained + * store — so JSX reading one field updates only when THAT field changes. + */ +import { createSignal } from 'solid-js' +import { render } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + act as write, + machine, + makeReaction, + type Connect, + type TransitionConfig, +} from '@dunky.dev/state-machine' +import { type ComponentEffects, useMachine } from '../src' + +type ToggleState = 'closed' | 'open' +interface ToggleCtx { + count: number +} +type ToggleEvent = { type: 'toggle' } + +interface ToggleProps { + label?: string + onOpenChange?: (open: boolean) => void +} + +const createConfig = + (): ((props: ToggleProps) => TransitionConfig) => () => ({ + initial: 'closed', + context: { count: 0 }, + states: { + closed: { + on: { toggle: { target: 'open', actions: write($ => ({ count: $.context.count + 1 })) } }, + }, + open: { on: { toggle: { target: 'closed' } } }, + }, + }) + +type ToggleApi = { + open: boolean + label: string | undefined + count: number + toggle: () => void +} + +const connect: Connect = ({ + state, + context, + props, + send, +}) => ({ + open: state === 'open', + label: props.label, + count: context.count, + toggle: () => send({ type: 'toggle' }), +}) + +const reaction = makeReaction() +connect.reactions = [ + reaction( + m => m.state === 'open', + (open, props) => props.onOpenChange?.(open), + ), +] + +type ToggleMachine = ReturnType> +const noEffects: ComponentEffects = [] + +afterEach(() => vi.clearAllMocks()) + +describe('useMachine — lifecycle', () => { + it('returns { api, machine }: api is the connect() output, machine is the running service', () => { + let captured: { api: ToggleApi; machine: ToggleMachine } | undefined + function Comp() { + const props: ToggleProps = { label: 'hi' } + captured = useMachine(createConfig(), connect, noEffects, props) + return
{captured.api.label}
+ } + render(() => ) + expect(captured!.api.open).toBe(false) + expect(captured!.api.label).toBe('hi') + expect(captured!.api.count).toBe(0) + expect(typeof captured!.api.toggle).toBe('function') + expect(typeof captured!.machine.send).toBe('function') + }) + + it('starts the machine on mount and stops it on cleanup', () => { + let api: ToggleApi | undefined + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connect, noEffects, props).api + return null + } + const { unmount } = render(() => ) + api!.toggle() + expect(api!.open).toBe(true) + expect(() => unmount()).not.toThrow() + }) + + it('updates the DOM fine-grained when the read field changes', () => { + let api: ToggleApi | undefined + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connect, noEffects, props).api + return
{api.open ? 'open' : 'closed'}
+ } + const { getByTestId } = render(() => ) + expect(getByTestId('state').textContent).toBe('closed') + api!.toggle() + expect(getByTestId('state').textContent).toBe('open') + expect(api!.count).toBe(1) + }) +}) + +describe('useMachine — fine-grained store', () => { + it('a field read updates ONLY when that field changes, not on unrelated changes', () => { + // `count` and `open` both live on the api store. A reader of `count` must not + // re-run when only an unrelated render happens, and the store reconciles in + // place so untouched leaves keep identity. We assert the store proxy reflects + // each field independently after a toggle. + let api: ToggleApi | undefined + const countReads = vi.fn() + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connect, noEffects, props).api + return ( + <> +
{(countReads(), api.count)}
+
{api.open ? 'y' : 'n'}
+ + ) + } + const { getByTestId } = render(() => ) + expect(getByTestId('count').textContent).toBe('0') + expect(getByTestId('open').textContent).toBe('n') + const countReadsBefore = countReads.mock.calls.length + + api!.toggle() // open: n→y AND count: 0→1 + expect(getByTestId('open').textContent).toBe('y') + expect(getByTestId('count').textContent).toBe('1') + expect(countReads.mock.calls.length).toBeGreaterThan(countReadsBefore) + }) +}) + +describe('useMachine — build once', () => { + it('builds the machine ONCE: state survives prop changes (no rebuild)', () => { + let api: ToggleApi | undefined + const [label, setLabel] = createSignal('a') + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + } + api = useMachine(createConfig(), connect, noEffects, props).api + return
{api.label}
+ } + render(() => ) + api!.toggle() // → open, count 1 + expect(api!.open).toBe(true) + + setLabel('b') // prop change must NOT rebuild/reset state + expect(api!.open).toBe(true) + expect(api!.count).toBe(1) + expect(api!.label).toBe('b') // but the new prop IS reflected + }) +}) + +describe('useMachine — props freshness via setProps', () => { + it('flows later prop changes into the snapshot (setProps, not rebuild)', () => { + let api: ToggleApi | undefined + const [label, setLabel] = createSignal('first') + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + } + api = useMachine(createConfig(), connect, noEffects, props).api + return null + } + render(() => ) + expect(api!.label).toBe('first') + setLabel('second') + expect(api!.label).toBe('second') + }) +}) + +describe('useMachine — reactions follow the machine lifecycle', () => { + it('fires the connect reaction (onOpenChange) when state flips while mounted', () => { + const onOpenChange = vi.fn() + let api: ToggleApi | undefined + function Comp() { + const props: ToggleProps = { onOpenChange } + api = useMachine(createConfig(), connect, noEffects, props).api + return null + } + render(() => ) + expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe + api!.toggle() + expect(onOpenChange).toHaveBeenCalledWith(true) + api!.toggle() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) + +describe('useMachine — component effects', () => { + it('runs each ComponentEffect (setup on mount, cleanup on unmount)', () => { + const setup = vi.fn() + const cleanup = vi.fn() + const effects: ComponentEffects = [[() => (setup(), cleanup), []]] + function Comp() { + const props: ToggleProps = {} + useMachine(createConfig(), connect, effects, props) + return null + } + const { unmount } = render(() => ) + expect(setup).toHaveBeenCalledOnce() + expect(cleanup).not.toHaveBeenCalled() + unmount() + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('re-runs an effect ONLY when one of its named prop deps changes', () => { + const fn = vi.fn(() => () => {}) + const effects: ComponentEffects = [[fn, ['label']]] + const [label, setLabel] = createSignal('a') + const [other, setOther] = createSignal(() => {}) + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + get onOpenChange() { + return other() + }, + } + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(fn).toHaveBeenCalledTimes(1) + + setOther(() => () => {}) // non-dep prop changed → no re-run + expect(fn).toHaveBeenCalledTimes(1) + + setLabel('b') // dep changed → re-run + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('receives (machine, props) and can read live machine state', () => { + let seenOpen: boolean | undefined + const effects: ComponentEffects = [ + [ + m => { + seenOpen = m.matches('open') + }, + [], + ], + ] + function Comp() { + const props: ToggleProps = {} + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(seenOpen).toBe(false) + }) +}) diff --git a/packages/solid/tests/use-selector.test.tsx b/packages/solid/tests/use-selector.test.tsx new file mode 100644 index 0000000..4483acc --- /dev/null +++ b/packages/solid/tests/use-selector.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +/** + * `useSelector` — fine-grained leaf subscription. These tests pin the contract: + * the selector reads the machine directly, the returned accessor updates ONLY + * when the selected value changes (value-deduped, Object.is by default, custom + * isEqual for object selections), and a change to one leaf's slice wakes only + * that leaf's accessor (the O(readers) property). + */ +import { createEffect } from 'solid-js' +import { render, renderHook } from '@solidjs/testing-library' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act as write, machine, type TransitionConfig } from '@dunky.dev/state-machine' +import { useSelector } from '../src' + +type S = 'idle' +interface Ctx { + a: number + b: number +} +type Ev = { type: 'incA' } | { type: 'incB' } | { type: 'noop' } + +const config: TransitionConfig = { + initial: 'idle', + context: { a: 0, b: 0 }, + states: { + idle: { + on: { + // context writes go through setContext (via `act`) so the bus notifies — + // a raw in-place `context.a++` mutates the value but never wakes subscribers. + incA: write($ => ({ a: $.context.a + 1 })), + incB: write($ => ({ b: $.context.b + 1 })), + noop: () => {}, + }, + }, + }, +} + +function makeMachine() { + const m = machine(config) + m.start() + return m +} + +afterEach(() => vi.clearAllMocks()) + +describe('useSelector — value-deduped accessor', () => { + it('reads the machine directly and reflects the selected value', () => { + const m = makeMachine() + const { result } = renderHook(() => useSelector(m, () => m.context.a)) + expect(result()).toBe(0) + m.send({ type: 'incA' }) + expect(result()).toBe(1) + }) + + it('updates the accessor ONLY when the selected slice changes', () => { + const m = makeMachine() + const reads = vi.fn() + const { result } = renderHook(() => useSelector(m, () => m.context.a)) + // A tracked reader of the accessor; it re-runs only when the signal changes. + createEffect(() => reads(result())) + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selects `a`, `b` changed → no update + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'noop' }) // nothing changed → no update + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // `a` changed → update + expect(reads).toHaveBeenCalledTimes(2) + }) + + it('defaults to Object.is equality (a re-derived equal value does not update)', () => { + const m = makeMachine() + const reads = vi.fn() + const { result } = renderHook(() => useSelector(m, () => m.context.a > 0)) + createEffect(() => reads(result())) + expect(reads).toHaveBeenCalledTimes(1) + m.send({ type: 'incA' }) // false → true (update) + expect(reads).toHaveBeenCalledTimes(2) + m.send({ type: 'incA' }) // true → true (no update) + expect(reads).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector — custom isEqual for object selections', () => { + it('uses the provided isEqual to dedup an object selection', () => { + const m = makeMachine() + const reads = vi.fn() + const { result } = renderHook(() => + useSelector( + m, + () => ({ a: m.context.a }), + (x, y) => x.a === y.a, + ), + ) + createEffect(() => reads(result())) + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selected {a} unchanged → no update + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // {a} changed → update + expect(reads).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector — O(readers): a slice change wakes only its reader', () => { + it('updates only the leaf whose selected slice changed', () => { + const m = makeMachine() + const aRenders = vi.fn() + const bRenders = vi.fn() + function LeafA() { + const a = useSelector(m, () => m.context.a) + return {(aRenders(), a())} + } + function LeafB() { + const b = useSelector(m, () => m.context.b) + return {(bRenders(), b())} + } + render(() => ( + <> + + + + )) + expect(aRenders).toHaveBeenCalledTimes(1) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // only LeafA's slice changed + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // only LeafB's slice changed + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json new file mode 100644 index 0000000..f00240b --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,9 @@ +{ + // Thin shim so editors and tsdown find a tsconfig inside the package. The real + // Solid project — JSX settings + the typecheck `include`/`exclude` — lives in + // tsconfig/solid.json (referenced by tsconfig/all.json). This re-declares + // `include` relative to the package dir for tools that resolve from here. + "extends": "../../tsconfig/solid.json", + "include": ["src", "tests"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f59d3f..dc60c53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,12 +35,18 @@ importers: publint: specifier: ^0.3.21 version: 0.3.21 + solid-js: + specifier: ^1.9.13 + version: 1.9.13 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 + vite-plugin-solid: + specifier: ^2.11.12 + version: 2.11.12(solid-js@1.9.13)(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: specifier: ^4.1.7 version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -180,6 +186,25 @@ importers: packages/shared/utils: {} + packages/solid: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:^ + version: link:../core + '@dunky.dev/state-machine-utils': + specifier: workspace:^ + version: link:../shared/utils + devDependencies: + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(solid-js@1.9.13) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + solid-js: + specifier: ^1.9.13 + version: 1.9.13 + sandbox/native: dependencies: '@dunky.dev/state-machine': @@ -455,6 +480,10 @@ packages: resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -2780,6 +2809,16 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3301,6 +3340,11 @@ packages: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -3354,6 +3398,15 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4294,6 +4347,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -4441,6 +4497,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4781,6 +4841,10 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -5836,6 +5900,16 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -5904,6 +5978,14 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + solid-js@1.9.13: + resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6419,6 +6501,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-solid@2.11.12: + resolution: {integrity: sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite@7.3.5: resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6985,6 +7077,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.7 + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -8482,6 +8578,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -9129,7 +9232,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-wasm32-wasi@1.1.1': @@ -9286,6 +9389,11 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solidjs/testing-library@0.8.10(solid-js@1.9.13)': + dependencies: + '@testing-library/dom': 10.4.1 + solid-js: 1.9.13 + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.3.1': @@ -9849,6 +9957,15 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.29.7 @@ -9950,6 +10067,13 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.13): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.13 + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -11028,6 +11152,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -11139,6 +11265,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -11637,6 +11765,10 @@ snapshots: memoize-one@5.2.1: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -13419,6 +13551,12 @@ snapshots: serialize-error@2.1.0: {} + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -13518,6 +13656,21 @@ snapshots: smol-toml@1.6.1: {} + solid-js@1.9.13: + dependencies: + csstype: 3.2.3 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + solid-refresh@0.6.3(solid-js@1.9.13): + dependencies: + '@babel/generator': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.7 + solid-js: 1.9.13 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -13944,6 +14097,19 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-solid@2.11.12(solid-js@1.9.13)(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: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13) + merge-anything: 5.1.7 + solid-js: 1.9.13 + solid-refresh: 0.6.3(solid-js@1.9.13) + 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)) + transitivePeerDependencies: + - supports-color + 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): dependencies: esbuild: 0.27.7 @@ -13997,6 +14163,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 diff --git a/tsconfig.all.json b/tsconfig.all.json deleted file mode 100644 index a4dd861..0000000 --- a/tsconfig.all.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - // Aggregator config: `tsc -b` typechecks all real projects (root + benchmark) - // in one pass. They stay separate because benchmark needs its own `types` - // (react-dom/jsdom) resolved from benchmark/node_modules. See `typecheck` script. - "files": [], - "references": [{ "path": "./tsconfig.json" }, { "path": "./benchmark/tsconfig.json" }] -} diff --git a/tsconfig.json b/tsconfig.json index 7dbe31a..864c273 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,3 @@ { - "compilerOptions": { - "target": "esnext", - "allowJs": false, - "noEmit": true, - "esModuleInterop": true, - "isolatedModules": true, - "jsx": "react-jsx", - "lib": ["dom", "dom.iterable", "esnext"], - "module": "esnext", - "moduleResolution": "bundler", - "noImplicitReturns": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "paths": { - "@dunky.dev/state-machine": ["./packages/core/src"], - "@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-utils": ["./packages/shared/utils/src"], - "@dunky.dev/state-machine-bindings": ["./packages/shared/bindings/src"] - }, - "types": ["@types/node", "vitest/globals"] - }, - "include": ["./*.ts", "./packages"], - "exclude": ["**/node_modules", "**/dist"] + "extends": "./tsconfig/base.json", } diff --git a/tsconfig/all.json b/tsconfig/all.json new file mode 100644 index 0000000..99d2fc5 --- /dev/null +++ b/tsconfig/all.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./react.json" }, + { "path": "./solid.json" }, + { "path": "../benchmark/tsconfig.json" } + ] +} diff --git a/tsconfig/base.json b/tsconfig/base.json new file mode 100644 index 0000000..eeb21d2 --- /dev/null +++ b/tsconfig/base.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "esnext", + "allowJs": false, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "noImplicitReturns": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "paths": { + "@dunky.dev/state-machine": ["../packages/core/src"], + "@dunky.dev/state-machine-react": ["../packages/react/src"], + "@dunky.dev/state-machine-solid": ["../packages/solid/src"], + "@dunky.dev/state-machine-native": ["../packages/native/src"], + "@dunky.dev/state-machine-opentui": ["../packages/opentui/src"], + "@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"], + "@dunky.dev/state-machine-bindings": ["../packages/shared/bindings/src"] + }, + "types": ["@types/node", "vitest/globals"] + } +} diff --git a/tsconfig/react.json b/tsconfig/react.json new file mode 100644 index 0000000..483885c --- /dev/null +++ b/tsconfig/react.json @@ -0,0 +1,8 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["../*.ts", "../packages"], + "exclude": ["../**/node_modules", "../**/dist", "../packages/solid"] +} diff --git a/tsconfig/solid.json b/tsconfig/solid.json new file mode 100644 index 0000000..c5ffdd1 --- /dev/null +++ b/tsconfig/solid.json @@ -0,0 +1,18 @@ +{ + // The Solid project: JSX is `preserve` + `solid-js` as the import source, so + // the Solid package and its tests see the Solid JSX namespace (not React's). + // Referenced from tsconfig/all.json so `tsc -b` typechecks it alongside the + // React project. `paths` is inherited from base (resolved relative to the base + // file's location), so no redeclaration is needed. + // + // `include`/`exclude` are relative to THIS file (tsconfig/), so they reach into + // the Solid package. `packages/solid/tsconfig.json` is a thin shim extending + // this, kept so editors + tsdown find a tsconfig inside the package. + "extends": "./base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "include": ["../packages/solid/src", "../packages/solid/tests"], + "exclude": ["../**/node_modules", "../**/dist"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 99165b5..ed2218b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ workspace: [ 'packages/core', 'packages/react', + 'packages/solid', 'packages/native', 'packages/opentui', 'packages/shared/utils', diff --git a/vitest.config.ts b/vitest.config.ts index c3bca12..273b8b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,42 @@ +import solid from 'vite-plugin-solid' import { defineConfig } from 'vitest/config' +// Two projects so the Solid tests get their JSX transform without touching the +// rest of the suite. The default project runs every package the way it always +// has (node, or jsdom via a per-file `@vitest-environment` comment) and EXCLUDES +// the Solid tests; the `solid` project owns `packages/solid/tests` with +// vite-plugin-solid (Solid JSX → reactive runtime) and the `solid-js` dev/browser +// conditions @solidjs/testing-library needs. Keeping the Solid plugin on its own +// project is what stops it from rewriting the React `.tsx` tests. export default defineConfig({ test: { - globals: false, - environment: 'node', - exclude: ['**/node_modules/**', '**/dist/**'], + projects: [ + { + test: { + name: 'default', + globals: false, + environment: 'node', + include: ['packages/**/tests/**/*.test.{ts,tsx}'], + exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], + }, + }, + { + plugins: [solid()], + resolve: { + // @solidjs/testing-library + the reactive runtime expect Solid's + // dev/browser build conditions. + conditions: ['development', 'browser'], + }, + test: { + name: 'solid', + globals: false, + // node by default; the DOM tests opt into jsdom per-file via a + // `@vitest-environment jsdom` comment (same convention as the React + // package), which also lets knip trace the jsdom devDependency. + environment: 'node', + include: ['packages/solid/tests/**/*.test.{ts,tsx}'], + }, + }, + ], }, }) diff --git a/website/astro.config.ts b/website/astro.config.ts index 7e05607..df6c9d5 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: 'Solid', link: 'libs/solid' }, { label: 'React Native', link: 'libs/react-native' }, { label: 'OpenTUI', link: 'libs/opentui' }, ], diff --git a/website/src/content/docs/libs/solid.mdx b/website/src/content/docs/libs/solid.mdx new file mode 100644 index 0000000..7463cc3 --- /dev/null +++ b/website/src/content/docs/libs/solid.mdx @@ -0,0 +1,199 @@ +--- +title: Solid +description: Solid bindings for @dunky.dev/state-machine. +--- + +import Install from '../../../components/install.astro' + + + +The Solid package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Solid: lifecycle, fine-grained reactivity, prop translation, and platform effects. **The machine itself is unchanged** — the same `createDialogConfig` and `connectDialog` that drive React run here. + +Unlike React, this is not a `useSyncExternalStore` bridge: the connector's snapshot is mirrored into a Solid **store**, so reading `api.isOpen` in JSX subscribes to exactly that field — only the markup that reads a changed field updates. + +## `useMachine` + +The one bridge hook. Every component calls it with the four agnostic pieces and gets back the view API as a reactive store: + +```tsx +import { useMachine, normalize } from '@dunky.dev/state-machine-solid' +import { createDialogConfig, connectDialog, dialogEffects } from './dialog' + +type DialogProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + closeOnEscape?: boolean +} + +function Dialog(props: DialogProps) { + const { api } = useMachine( + createDialogConfig, // (props) => MachineConfig; seeds context once + connectDialog, // pure connect(): snapshot → view api + dialogEffects, // ComponentEffect[]: DOM listeners, gated by props + props, + ) + + return ( + <> + + {api.isOpen &&
Dialog content
} + + ) +} +``` + +`useMachine` builds the machine and connector **once** (a Solid component body runs a single time, so the first props seed context; later changes flow through `setProps`, not a rebuild), starts on mount, stops on cleanup, and exposes the connect output as a fine-grained store. `api` is the store proxy — read its fields directly in JSX; do **not** destructure it (`const { isOpen } = api` snapshots the value and loses reactivity). + +### The three imports + +Those three values are where the dialog's behavior actually lives, and none of it is Solid. You write them once, in a `./dialog` module, and they run unchanged on any platform: + +```ts +// dialog.ts: plain functions, no Solid +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 JSX 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 Solid-specific piece: `createDialogConfig` is the machine definition, `connectDialog` is the snapshot-to-view-API mapping, and `dialogEffects` are the DOM listeners. This is the **same** `./dialog` module the [React page](/libs/react) imports — only the bridge differs. 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 as Solid's JSX expects them: + +```ts +normalize(api.triggerProps) +// { onClick, 'aria-expanded', role, tabindex, ... } +``` + +| Binding | DOM prop | +| ----------------------------------------------- | ------------------------------------------------------------------- | +| `onPress` | `onClick` | +| `onValueChange` | `onInput` (payload adapted to `ChangePayload`) | +| `onDoublePress` | `onDblClick` | +| `onPointerEnter/Leave/Move/Down/Up/Cancel` | same name | +| `onFocus` / `onBlur` / `onKeyDown` / `onKeyUp` | same name | +| `onWheel` / `onScroll` / `onScrollEnd` | same name (payload adapted to `WheelPayload` / `ScrollPayload`) | +| `describedBy` / `labelledBy` | `aria-describedby` / `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 differences from the [React](/libs/react) DOM normalizer are Solid's JSX conventions: `onValueChange → onInput` (Solid forwards native input events), `onDoublePress → onDblClick`, and `focusable → tabindex` (lowercase, the real attribute). `undefined` values are dropped; unknown keys (`class`, `data-*`) pass through unchanged. + +A few handlers whose agnostic payload differs from the raw event (`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer receives the agnostic payload, built from the native DOM event. + +## `mergeProps`: consumer + component props + +When a consumer spreads their own props onto the same element the component controls: + +```tsx + + + +
api.setOpen(false)}> +
e.stopPropagation()}> + (inputEl = el)} + {...normalize(api.parts.input)} + value={api.query} + placeholder='Type a command…' + style={styles.input} + /> +
    + +
  • No results
  • +
    + + {(command, index) => { + const itemProps = () => normalize(api.parts.getItemProps(command, index())) + const selected = () => command.id === api.activeId + return ( +
  • + {command.label} + + {command.hint} + +
  • + ) + }} +
    +
+
+
+
+
+ ) +} + +const styles: Record = { + trigger: { + display: 'flex', + 'justify-content': 'space-between', + 'min-width': '300px', + 'align-items': 'center', + gap: '8px', + padding: '10px 14px', + 'font-size': '14px', + color: '#5b6172', + background: '#fff', + border: '1px solid rgba(13,15,22,0.12)', + 'border-radius': '10px', + cursor: 'pointer', + }, + kbd: { + 'font-family': 'ui-monospace, monospace', + 'font-size': '11px', + color: '#8990a0', + background: 'rgba(13,15,22,0.05)', + border: '1px solid rgba(13,15,22,0.08)', + 'border-radius': '6px', + padding: '2px 6px', + }, + backdrop: { + position: 'fixed', + inset: 0, + background: 'rgba(13,15,22,0.35)', + display: 'flex', + 'justify-content': 'center', + 'align-items': 'flex-start', + 'padding-top': '14vh', + }, + panel: { + width: 'min(560px, 92vw)', + background: '#fff', + 'border-radius': '14px', + 'box-shadow': '0 24px 64px rgba(13,15,22,0.28)', + overflow: 'hidden', + }, + input: { + width: '100%', + 'min-width': '300px', + 'box-sizing': 'border-box', + padding: '18px 20px', + 'font-size': '16px', + border: 'none', + 'border-bottom': '1px solid rgba(13,15,22,0.08)', + outline: 'none', + 'border-top-left-radius': '8px', + 'border-top-right-radius': '8px', + }, + list: { + 'list-style': 'none', + margin: 0, + padding: '8px', + 'max-height': '320px', + 'overflow-y': 'auto', + }, + item: { + display: 'flex', + 'justify-content': 'space-between', + 'align-items': 'center', + padding: '10px 12px', + 'border-radius': '8px', + 'font-size': '14px', + color: '#1c1e26', + cursor: 'pointer', + }, + itemActive: { background: 'rgba(91,115,255,0.12)', color: '#3142c4' }, + empty: { padding: '16px 12px', color: '#8990a0', 'font-size': '14px' }, +} diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx new file mode 100644 index 0000000..6d7e41d --- /dev/null +++ b/sandbox/solid/src/main.tsx @@ -0,0 +1,7 @@ +import { render } from 'solid-js/web' +import { App } from './app' + +const root = document.getElementById('root') +if (!root) throw new Error('missing #root') + +render(() => , root) diff --git a/sandbox/solid/tsconfig.json b/sandbox/solid/tsconfig.json new file mode 100644 index 0000000..e437a25 --- /dev/null +++ b/sandbox/solid/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["node"] + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts new file mode 100644 index 0000000..30b1ab2 --- /dev/null +++ b/sandbox/solid/vite.config.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path' +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vite' + +// The @dunky.dev/* packages and the shared cmdk core all point `main` at their TS +// `src/index.ts` (no build step). Alias each to its source so Vite transpiles them +// directly — the whole point of the sandbox is to run the workspace source live. +export default defineConfig({ + plugins: [solid()], + resolve: { + alias: { + '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine-solid': resolve(__dirname, '../../packages/solid/src'), + '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), + '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), + '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), + }, + }, +}) From d4b395ced11c0cc2c2ced43d5251c8fbc30f1d70 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 17 Aug 2026 23:39:38 +0200 Subject: [PATCH 03/21] refactor(solid): adopt the substrate-prefix name and manifest conventions Renames the package to @dunky.dev/solid-state-machine (the convention from #43) across the manifest, changelog, tsconfig path alias, sandbox, and changeset. Conforms the manifest to main: ship src/ in the published files (#53), pin internal workspace deps exact (#59), and add the bindings devDep the translation contract needs. The changeset now also states the version policy: solid-js ^1.6 today; Solid 2.0 lands as a separate major once stable. Co-Authored-By: Claude Fable 5 --- .changeset/solid-integration.md | 11 +++++++++-- packages/solid/CHANGELOG.md | 2 +- packages/solid/package.json | 10 ++++++---- pnpm-lock.yaml | 13 ++++++++----- sandbox/solid/package.json | 2 +- sandbox/solid/src/command-palette.tsx | 2 +- sandbox/solid/vite.config.ts | 2 +- tsconfig/base.json | 2 +- 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md index fb5c0fb..799d7f7 100644 --- a/.changeset/solid-integration.md +++ b/.changeset/solid-integration.md @@ -1,8 +1,8 @@ --- -'@dunky.dev/state-machine-solid': minor +'@dunky.dev/solid-state-machine': minor --- -Add `@dunky.dev/state-machine-solid` — the Solid bindings target. +Add `@dunky.dev/solid-state-machine` — the Solid bindings target. A first-class Solid bridge (not a React re-export): `useMachine` mirrors the connector's snapshot into a Solid `createStore` (via `reconcile`) so reading a @@ -13,3 +13,10 @@ Solid accessor. `normalize` maps the agnostic bindings to Solid DOM props (`onInput`, `onDblClick`, `tabindex`) and `mergeProps` applies Solid's `class` concat + single-object `style` merge. The same `connect` and machine config run unchanged across React, Solid, React Native, and OpenTUI. + +Supports `solid-js` `^1.6` (the 1.x line). Solid 2.0 — a release candidate as +of August 2026 — removes the exact surface this bridge stands on +(`solid-js/store`, single-argument `createEffect`, `onMount`, the 1.x +`reconcile` calling convention), so, like the rest of the Solid ecosystem +(router, TanStack, solid-primitives), 2.0 support will ship as a separate major +once 2.0 is stable rather than as a dual-version range. diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md index 092b0ac..08a5d31 100644 --- a/packages/solid/CHANGELOG.md +++ b/packages/solid/CHANGELOG.md @@ -1 +1 @@ -# @dunky.dev/state-machine-solid +# @dunky.dev/solid-state-machine diff --git a/packages/solid/package.json b/packages/solid/package.json index b711b2d..76a612b 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,5 +1,5 @@ { - "name": "@dunky.dev/state-machine-solid", + "name": "@dunky.dev/solid-state-machine", "version": "0.2.0", "license": "MIT", "repository": { @@ -8,7 +8,8 @@ "directory": "packages/solid" }, "files": [ - "dist" + "dist", + "src" ], "type": "module", "sideEffects": false, @@ -33,10 +34,11 @@ "build": "tsdown" }, "dependencies": { - "@dunky.dev/state-machine": "workspace:^", - "@dunky.dev/state-machine-utils": "workspace:^" + "@dunky.dev/state-machine": "workspace:*", + "@dunky.dev/state-machine-utils": "workspace:*" }, "devDependencies": { + "@dunky.dev/state-machine-bindings": "workspace:*", "@solidjs/testing-library": "^0.8.10", "jsdom": "^29.1.1", "solid-js": "^1.9.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f42f56..463dd58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,12 +198,15 @@ importers: packages/solid: dependencies: '@dunky.dev/state-machine': - specifier: workspace:^ + specifier: workspace:* version: link:../core '@dunky.dev/state-machine-utils': - specifier: workspace:^ + specifier: workspace:* version: link:../shared/utils devDependencies: + '@dunky.dev/state-machine-bindings': + specifier: workspace:* + version: link:../shared/bindings '@solidjs/testing-library': specifier: ^0.8.10 version: 0.8.10(solid-js@1.9.13) @@ -339,15 +342,15 @@ importers: sandbox/solid: dependencies: + '@dunky.dev/solid-state-machine': + specifier: workspace:^ + version: link:../../packages/solid '@dunky.dev/state-machine': specifier: workspace:^ version: link:../../packages/core '@dunky.dev/state-machine-bindings': specifier: workspace:^ version: link:../../packages/shared/bindings - '@dunky.dev/state-machine-solid': - specifier: workspace:^ - version: link:../../packages/solid '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json index 06834b0..435935f 100644 --- a/sandbox/solid/package.json +++ b/sandbox/solid/package.json @@ -9,9 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@dunky.dev/solid-state-machine": "workspace:^", "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", - "@dunky.dev/state-machine-solid": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", "@sandbox/cmdk-core": "workspace:^", "solid-js": "^1.9.13" diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx index d2a5bd6..06c2952 100644 --- a/sandbox/solid/src/command-palette.tsx +++ b/sandbox/solid/src/command-palette.tsx @@ -1,5 +1,5 @@ import { createEffect, For, type JSX, Show } from 'solid-js' -import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/state-machine-solid' +import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' import { commandPaletteMachineConfig, type CommandPaletteMachine, diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index 30b1ab2..7eaf45e 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ resolve: { alias: { '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), - '@dunky.dev/state-machine-solid': resolve(__dirname, '../../packages/solid/src'), + '@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), diff --git a/tsconfig/base.json b/tsconfig/base.json index 4ec153b..49f680f 100644 --- a/tsconfig/base.json +++ b/tsconfig/base.json @@ -15,7 +15,7 @@ "paths": { "@dunky.dev/state-machine": ["../packages/core/src"], "@dunky.dev/react-state-machine": ["../packages/react/src"], - "@dunky.dev/state-machine-solid": ["../packages/solid/src"], + "@dunky.dev/solid-state-machine": ["../packages/solid/src"], "@dunky.dev/native-state-machine": ["../packages/native/src"], "@dunky.dev/opentui-state-machine": ["../packages/opentui/src"], "@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"], From c33432d770aef97b846c88abc87f06c615a732f8 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 17 Aug 2026 23:39:59 +0200 Subject: [PATCH 04/21] feat(solid): adopt the bindings translation contract and current target APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Solid target up to main's contract changes: - normalize maps are vocabulary-typed (HandlerTargets/AttrTargets) and exported, with keyed lookups — a typo or unknown key is now a compile error (#57/#60). The header carries the non-mechanical translation rationale ACCESSIBILITY.md asks for (focusable -> tabindex 0/-1, disabled -> aria-disabled per APG). - the shared describeVocabularyAccounting fixture runs against the solid maps — the last target missing the conformance suite. - ComponentEffects is folded into ComponentEffect; the effects param is a plain ComponentEffect[] (#49). - mergeProps is generic over the consumer's props, cast-free at call sites, with the expectTypeOf regression test (#51). - tests import the published entry (@dunky.dev/solid-state-machine) instead of ../src, matching every other target. Co-Authored-By: Claude Fable 5 --- packages/solid/src/index.ts | 2 +- packages/solid/src/merge-props.ts | 20 ++++++----- packages/solid/src/normalize.ts | 39 ++++++++++++++++------ packages/solid/src/use-machine.ts | 23 +++++-------- packages/solid/tests/merge-props.test.ts | 18 ++++++++-- packages/solid/tests/normalize.test.ts | 11 +++++- packages/solid/tests/use-machine.test.tsx | 10 +++--- packages/solid/tests/use-selector.test.tsx | 2 +- 8 files changed, 82 insertions(+), 43 deletions(-) diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index d113d89..eac700c 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -1,4 +1,4 @@ -export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine' +export { useMachine, type ComponentEffect } from './use-machine' export { useSelector } from './use-selector' export { normalize, type Bindings } from './normalize' export { mergeProps } from './merge-props' diff --git a/packages/solid/src/merge-props.ts b/packages/solid/src/merge-props.ts index 93be260..419e714 100644 --- a/packages/solid/src/merge-props.ts +++ b/packages/solid/src/merge-props.ts @@ -16,18 +16,22 @@ type AnyProps = Record * library-wins; mixing a string and an object on the same element is a consumer * error Solid itself wouldn't merge either.) */ -export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { - const merged = baseMergeProps(consumer, library) - if (!consumer) return merged +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 - if (typeof consumer.class === 'string' && typeof library.class === 'string') { - merged.class = `${consumer.class} ${library.class}`.trim() + if (typeof own.class === 'string' && typeof library.class === 'string') { + merged.class = `${own.class} ${library.class}`.trim() } - if (isStyleObject(consumer.style) && isStyleObject(library.style)) { - merged.style = { ...consumer.style, ...library.style } + if (isStyleObject(own.style) && isStyleObject(library.style)) { + merged.style = { ...own.style, ...library.style } } - return merged + return merged as Props & AnyProps } function isStyleObject(v: unknown): v is Record { diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts index 847d09a..f51d8f3 100644 --- a/packages/solid/src/normalize.ts +++ b/packages/solid/src/normalize.ts @@ -1,8 +1,21 @@ /** * Translate the machine layer's LOGICAL surface to Solid DOM props. * - * Logical handler → DOM event prop - * Logical attr → DOM/ARIA attr + * Input keys are the substrate-agnostic vocabulary a connect() emits + * (`EventBindings` / `AttrBindings` in `@dunky.dev/state-machine-bindings`), + * which is ARIA-shaped by design — see `ACCESSIBILITY.md`. The DOM is the + * closest host to that vocabulary, so most attrs are a mechanical `aria-` + * prefix and nothing is dropped. The parts that aren't mechanical: + * - `onPress` → `onClick`: the DOM's activation event, which fires for + * keyboard Enter/Space on a native control too, not just a mouse press. + * - `focusable` → `tabindex` 0 / -1, not a boolean — `false` still has to + * leave the element focusable in script. + * - `disabled` → `aria-disabled`, never the HTML `disabled` attribute: a + * disabled control stays in the tab order and keeps announcing itself, + * per APG. A consumer that wants the HTML attribute passes it themselves. + * - `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` also have their + * argument translated — the DOM event is read into the neutral payload + * shape (see PAYLOAD_ADAPTERS), never forwarded raw. * * Differences from the React DOM normalizer worth flagging: * @@ -10,13 +23,21 @@ * `onClick` is fine (Solid delegates it), and the event handed to a handler is * a real `MouseEvent`/`PointerEvent`/`KeyboardEvent`/`WheelEvent`, so the * payload adapters below read the native event shape (same field names). + * - `onValueChange` lands on `onInput` (Solid's per-change event; Solid's + * `onChange` fires only on commit) and `onDoublePress` on `onDblClick`. * - Solid uses lowercase `tabindex` (the real attribute), not React's camelCase * `tabIndex`. That's the only attr name that differs from the DOM normalizer — * the ARIA attributes (`aria-*`) are written verbatim in Solid JSX, exactly as * here. */ +import type { + AttrKey, + AttrTargets, + HandlerKey, + HandlerTargets, +} from '@dunky.dev/state-machine-bindings' -const HANDLER_MAP: Record = { +export const HANDLER_MAP: HandlerTargets = { onPress: 'onClick', onPointerEnter: 'onPointerEnter', onPointerLeave: 'onPointerLeave', @@ -91,7 +112,7 @@ function scrollPayload(e: AnyEvent): unknown { } } -const ATTR_MAP: Record = { +export const ATTR_MAP: AttrTargets = { describedBy: 'aria-describedby', labelledBy: 'aria-labelledby', controls: 'aria-controls', @@ -153,7 +174,7 @@ export function normalize(logical: Bindings): Record { for (const [key, value] of Object.entries(logical)) { if (value === undefined) continue - const handler = HANDLER_MAP[key] + const handler = HANDLER_MAP[key as HandlerKey] if (handler) { const adapt = PAYLOAD_ADAPTERS[key] // Wrap when the agnostic payload differs from the raw DOM event; else the @@ -162,13 +183,9 @@ export function normalize(logical: Bindings): Record { continue } - const attr = ATTR_MAP[key] + const attr = ATTR_MAP[key as AttrKey] if (attr) { - if (key === 'focusable') { - out[attr] = value ? 0 : -1 - } else { - out[attr] = value - } + out[attr] = key === 'focusable' ? (value ? 0 : -1) : value continue } diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index b7adf99..63251d0 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -22,25 +22,20 @@ import { connector, machine, type Connect, type TransitionConfig } from '@dunky. * component's effects are authored ONCE and run unchanged everywhere; only how * the bridge consumes the deps differs (a manual dep array on React, reactive * reads here). + * + * A component passes `useMachine` a plain `ComponentEffect[]` list — several + * independent effects with DIFFERENT deps each get their own `createEffect`, so + * only the one whose dep changed re-subscribes. Unlike React there's no + * rules-of-hooks constraint here (a `createEffect` is not a hook), but keeping + * the list a stable module constant (`export const xEffects = [...]`) is still + * the convention — it reads identically across targets and never rebuilds the + * effect closures per call. */ 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 `createEffect` so only the one whose dep changed re-subscribes. - * - * Unlike React there's no rules-of-hooks constraint here (a `createEffect` is - * not a hook), but keeping it a stable module constant (`export const xEffects = - * [...]`) is still the convention — it reads identically across targets and - * never rebuilds the effect closures per call. - */ -export type ComponentEffects = ComponentEffect[] - /** * The one generic Solid bridge. Every component's generated api.ts calls this * with the agnostic pieces — a config factory and the connect — plus the @@ -74,7 +69,7 @@ export function useMachine< >( createConfig: (props: Props) => TransitionConfig, connect: Connect, - effects: ComponentEffects>, Props>, + effects: ComponentEffect>, Props>[], props: Props, ): { api: Api; machine: ReturnType> } { // Build machine + connector once. A Solid component body runs a single time, diff --git a/packages/solid/tests/merge-props.test.ts b/packages/solid/tests/merge-props.test.ts index cec0e17..0b121e0 100644 --- a/packages/solid/tests/merge-props.test.ts +++ b/packages/solid/tests/merge-props.test.ts @@ -5,8 +5,8 @@ * and `style` merged into ONE object (Solid's style is an object, not React's * array form). */ -import { describe, expect, it, vi } from 'vitest' -import { mergeProps } from '../src' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { mergeProps } from '@dunky.dev/solid-state-machine' describe('solid mergeProps', () => { it('inherits handler composition from the agnostic base', () => { @@ -67,3 +67,17 @@ describe('solid mergeProps', () => { expect(out.class).toBe('x') }) }) + +describe('solid mergeProps typing', () => { + it('preserves a typed consumer through the style merge', () => { + interface ButtonLikeProps { + style?: Record + class?: string + onClick?: () => void + } + const consumer: ButtonLikeProps = { style: { color: 'red' } } + const out = mergeProps(consumer, { style: { color: 'blue' } }) + expectTypeOf(out).toExtend() + expect(out.style).toEqual({ color: 'blue' }) + }) +}) diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts index c31de38..757da8d 100644 --- a/packages/solid/tests/normalize.test.ts +++ b/packages/solid/tests/normalize.test.ts @@ -9,7 +9,9 @@ * tabindex` (lowercase). */ import { describe, expect, it, vi } from 'vitest' -import { normalize } from '../src' +import { normalize } from '@dunky.dev/solid-state-machine' +import { ATTR_MAP, HANDLER_MAP } from '../src/normalize' +import { describeVocabularyAccounting } from '../../shared/bindings/tests/fixtures/vocabulary-accounting' describe('solid normalize — handlers', () => { it('maps onPress to onClick (the DOM activation event)', () => { @@ -51,6 +53,11 @@ describe('solid normalize — attributes', () => { }) }) + 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 }), @@ -264,3 +271,5 @@ describe('solid normalize — expanded attribute surface', () => { expect(onValueChange).toHaveBeenCalledWith(expect.objectContaining({ value: '50' })) }) }) + +describeVocabularyAccounting('solid', normalize, { map: HANDLER_MAP }, { map: ATTR_MAP }) diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx index 8930f17..07b2921 100644 --- a/packages/solid/tests/use-machine.test.tsx +++ b/packages/solid/tests/use-machine.test.tsx @@ -17,7 +17,7 @@ import { type Connect, type TransitionConfig, } from '@dunky.dev/state-machine' -import { type ComponentEffects, useMachine } from '../src' +import { type ComponentEffect, useMachine } from '@dunky.dev/solid-state-machine' type ToggleState = 'closed' | 'open' interface ToggleCtx { @@ -70,7 +70,7 @@ connect.reactions = [ ] type ToggleMachine = ReturnType> -const noEffects: ComponentEffects = [] +const noEffects: ComponentEffect[] = [] afterEach(() => vi.clearAllMocks()) @@ -214,7 +214,7 @@ describe('useMachine — component effects', () => { it('runs each ComponentEffect (setup on mount, cleanup on unmount)', () => { const setup = vi.fn() const cleanup = vi.fn() - const effects: ComponentEffects = [[() => (setup(), cleanup), []]] + const effects: ComponentEffect[] = [[() => (setup(), cleanup), []]] function Comp() { const props: ToggleProps = {} useMachine(createConfig(), connect, effects, props) @@ -229,7 +229,7 @@ describe('useMachine — component effects', () => { it('re-runs an effect ONLY when one of its named prop deps changes', () => { const fn = vi.fn(() => () => {}) - const effects: ComponentEffects = [[fn, ['label']]] + const effects: ComponentEffect[] = [[fn, ['label']]] const [label, setLabel] = createSignal('a') const [other, setOther] = createSignal(() => {}) function Comp() { @@ -256,7 +256,7 @@ describe('useMachine — component effects', () => { it('receives (machine, props) and can read live machine state', () => { let seenOpen: boolean | undefined - const effects: ComponentEffects = [ + const effects: ComponentEffect[] = [ [ m => { seenOpen = m.matches('open') diff --git a/packages/solid/tests/use-selector.test.tsx b/packages/solid/tests/use-selector.test.tsx index 4483acc..c582576 100644 --- a/packages/solid/tests/use-selector.test.tsx +++ b/packages/solid/tests/use-selector.test.tsx @@ -10,7 +10,7 @@ import { createEffect } from 'solid-js' import { render, renderHook } from '@solidjs/testing-library' import { afterEach, describe, expect, it, vi } from 'vitest' import { act as write, machine, type TransitionConfig } from '@dunky.dev/state-machine' -import { useSelector } from '../src' +import { useSelector } from '@dunky.dev/solid-state-machine' type S = 'idle' interface Ctx { From 4c28cc2157db45af5e3c82ca115ce4ee1e73fd06 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 17 Aug 2026 23:40:11 +0200 Subject: [PATCH 05/21] fix(sandbox): harden the solid demo and align the demo copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dedupe solid-js in the solid sandbox's vite config: the package alias points inside packages/solid, which carries its own solid-js devDep, so version skew would load two runtimes and silently kill reactivity. - clear the input ref on the Show branch's disposal — the closed palette kept a detached alive until the next open. - sandbox README now tells the four-substrates story: solid in the tree and run instructions, and the lifecycle-hook claim rewritten (three targets share the React hook; Solid brings its own bridge). - react demo copy mentions the Solid version, its page title disambiguates (cmdk - React), and a user-visible Palette typo is fixed in both apps. Co-Authored-By: Claude Fable 5 --- sandbox/README.md | 34 +++++++++++++++++---------- sandbox/react/index.html | 2 +- sandbox/react/src/app.tsx | 4 ++-- sandbox/solid/src/app.tsx | 2 +- sandbox/solid/src/command-palette.tsx | 9 +++++-- sandbox/solid/vite.config.ts | 4 ++++ 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/sandbox/README.md b/sandbox/README.md index 3adf23c..498dde7 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -1,7 +1,7 @@ -# cmdk sandbox — one machine, three substrates +# cmdk sandbox — one machine, four substrates A ⌘K **command palette** driven by a single substrate-agnostic state machine, -rendered three ways. The interesting parts — fuzzy filtering, arrow-key +rendered four ways. The interesting parts — fuzzy filtering, arrow-key navigation with wraparound, active-row tracking, selection — all live in `shared/`, the same bytes on every target. Each app only supplies the markup and runs its substrate's `normalize()` over the bindings the shared `connect()` @@ -9,25 +9,33 @@ produces. ``` sandbox/ -├── shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework) -├── react/ Vite + React DOM → normalize → onClick / aria-* / role -├── opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells -└── native/ Expo + React Native → normalize → onPress / accessibilityState ++-- shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework) ++-- react/ Vite + React DOM → normalize → onClick / aria-* / role ++-- solid/ Vite + Solid → normalize → onClick / aria-* / tabindex ++-- opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells ++-- native/ Expo + React Native → normalize → onPress / accessibilityState ``` -The split that makes this work: the lifecycle hook (`useMachine`) comes from -`@dunky.dev/react-state-machine` — all three targets render through a React -reconciler — while the **prop translator** (`normalize`) comes from each target's -own package. The OpenTUI app is the clearest proof: it imports `useMachine` from -the React binding and `normalize` from `@dunky.dev/opentui-state-machine`, exactly +The split that makes this work: the **prop translator** (`normalize`) comes from +each target's own package, while the lifecycle hook (`useMachine`) comes from +whichever bridge fits the substrate. React, OpenTUI, and React Native all render +through a React reconciler, so they share `@dunky.dev/react-state-machine`'s +hook — the OpenTUI app is the clearest proof: it imports `useMachine` from the +React binding and `normalize` from `@dunky.dev/opentui-state-machine`, exactly the "bring your own framework hook, pair it with the agnostic translator" model. +Solid is the proof from the other side: `@dunky.dev/solid-state-machine` brings +its own fine-grained `useMachine` — no React reconciler anywhere — and the same +shared machine + `connect()` run unchanged. ## Run ```bash -# DOM — opens at http://localhost:5173 +# DOM (React) — opens at http://localhost:5173 pnpm -C sandbox/react dev +# DOM (Solid) — opens at http://localhost:5173 +pnpm -C sandbox/solid dev + # Terminal — needs Bun. Press ⌘K / Ctrl+K to open the palette. pnpm -C sandbox/opentui dev @@ -35,5 +43,5 @@ pnpm -C sandbox/opentui dev pnpm -C sandbox/native start # then press i / a, or scan the QR ``` -All three consume the workspace packages straight from their TypeScript `src/` +All four consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. diff --git a/sandbox/react/index.html b/sandbox/react/index.html index 216ff7f..8fc30d7 100644 --- a/sandbox/react/index.html +++ b/sandbox/react/index.html @@ -3,7 +3,7 @@ - cmdk · DOM + cmdk · React
diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 90291a0..7d23059 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -7,7 +7,7 @@ export function App() { return (
-

⌘K Command Pallete

+

⌘K Command Palette


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

Last selected: {last} diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index d74e46e..8e167d2 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -8,7 +8,7 @@ export function App() { return (

-

⌘K Command Pallete

+

⌘K Command Palette


api.setOpen(false)}>
e.stopPropagation()}> (inputEl = el)} + // Clear on the Show branch's disposal — otherwise the closed + // palette keeps a detached alive until the next open. + ref={el => { + inputEl = el + onCleanup(() => (inputEl = undefined)) + }} {...normalize(api.parts.input)} value={api.query} placeholder='Type a command…' diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index 7eaf45e..f0f892a 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -8,6 +8,10 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [solid()], resolve: { + // The package alias points Vite inside packages/solid, which carries its own + // solid-js devDep — dedupe so the app and the package share ONE Solid runtime + // (two copies means silently dead reactivity on any version skew). + dedupe: ['solid-js'], alias: { '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), '@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'), From 35ad7598f2ab7c0a2abce4bb4b2d7cdc6dbbdce4 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Mon, 17 Aug 2026 23:40:35 +0200 Subject: [PATCH 06/21] docs: add solid to every target enumeration and fix the solid docs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solid existed in no doc a contributor or consumer reads first: - root README target diagram gains the Solid box; AGENTS.md, ARCHITECTURE.md, and the ACCESSIBILITY.md hidden fan-out example now enumerate all four targets. - the solid docs page examples were silently broken: string-shorthand transitions the core no-ops, and a bare config object instead of setup.infer().createMachine — both now mirror the react page. Also: Show instead of the React && idiom, the mergeProps import line, the stale mapping table replaced with the source link (the 521440e convention), cross-target links, and the solid-js version-support note. - api/effects.mdx no longer states the React-only hooks rule as universal and points at the Solid bridge alongside React Native. - the package README gains the Quick start, the flow diagram, the current heading conventions, and the Solid version support section (^1.6 now; 2.0 as a separate major once stable, with the migration mapped). - drop a trailing comma in the root tsconfig left by the merge resolution. Co-Authored-By: Claude Fable 5 --- ACCESSIBILITY.md | 1 + AGENTS.md | 14 +- ARCHITECTURE.md | 22 ++-- README.md | 16 +-- packages/solid/README.md | 157 +++++++++++++++++++---- tsconfig.json | 2 +- website/src/content/docs/api/effects.mdx | 7 +- website/src/content/docs/libs/solid.mdx | 53 ++++---- 8 files changed, 188 insertions(+), 84 deletions(-) diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index bfbb779..f073b9b 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -81,6 +81,7 @@ record: hidden: true | +-- react -> aria-hidden + +-- solid -> aria-hidden +-- native -> aria-hidden (RN's web-aligned alias, fanned out per platform) +-- opentui -> visible={false} (no accessibility tree; the visual analog) ``` diff --git a/AGENTS.md b/AGENTS.md index e55c135..ae48362 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ repo. This file is the canonical entry point: read it first, every time. This is Dunky's state-machine monorepo: UI behavior authored once as plain TypeScript state machines (`packages/core`), rendered anywhere -through thin per-substrate targets (`react`, `native`, `opentui`), with +through thin per-substrate targets (`react`, `solid`, `native`, `opentui`), with a benchmark suite, per-substrate sandboxes, and the docs website alongside. @@ -24,12 +24,12 @@ editing files in that scope — it overrides anything here for that scope ## Scopes -| Scope | Path | What it is | -| --------- | ------------- | ---------------------------------------------------------------------------------- | -| Packages | `packages/**` | The core machine, substrate targets (react, native, opentui), and shared internals | -| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries | -| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | -| Website | `website/` | The docs site | +| Scope | Path | What it is | +| --------- | ------------- | ----------------------------------------------------------------------------------------- | +| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals | +| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries | +| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | +| Website | `website/` | The docs site | Some changes are cross-scope: a change in `core/` may need follow-up in the targets, sandboxes, and docs — and vice versa. Check what else your diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 421a558..24cedc0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,7 +29,7 @@ The host | bridged per target v +------------------------------------------------------------------------+ -| (react, native, opentui, …) | +| (react, solid, native, opentui, …) | | Runtime-specific bridge | | • lifecycle (build + start/stop) • normalize bindings -> props | | • selector subscription | @@ -58,7 +58,7 @@ actions. Nothing in `core/` knows that React or the DOM exists. substrate-agnostic event and attr vocabulary (`onPress`, `role`, …); `shared/utils` owns cross-target helpers (mergeProps, composeHandlers, positioning). -**`/`** is the substrate side — `react`, `native`, `opentui`, and any +**`/`** is the substrate side — `react`, `solid`, `native`, `opentui`, and any future renderer. Each target is the runtime bridge for one environment: the lifecycle bridge, the event normalization, and the selector subscription all live here. @@ -93,12 +93,12 @@ Zag, whose machines read props directly.) ## Project structure -| File / location | What it owns | -| --------------------------- | ------------------------------------------------------------- | -| `packages/core/` | State-machine engine (plain-mutation kernel) | -| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | -| `packages/shared/utils/` | mergeProps, composeHandlers, positioning, memo | -| `packages//` | Hook + normalize per substrate (react, native, opentui, …) | +| File / location | What it owns | +| --------------------------- | ----------------------------------------------------------------- | +| `packages/core/` | State-machine engine (plain-mutation kernel) | +| `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | +| `packages/shared/utils/` | mergeProps, composeHandlers, positioning, memo | +| `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | ## The map @@ -121,7 +121,7 @@ shared/bindings substrate-agnostic event + attr vocabulary shared/utils cross-target, cross-component helpers +-- (composeHandlers, positioning, memo, mergeProps) - one substrate (react, native, opentui, …) + one substrate (react, solid, native, opentui, …) | runtime, hooks, and props translator +-- use-machine lifecycle bridge (build + start/stop + useSyncExternalStore) +-- use-selector fine-grained leaf subscription (O(readers)) @@ -136,7 +136,7 @@ Three package groups, three jobs: event + attr vocabulary; `shared/utils` owns agnostic helpers (positioning, prop merging, memoization). - **`/`** — _the substrate side_. One folder per renderer - (`react`, `native`, `opentui`). Owns its runtime bridge and its props translator. + (`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator. ## The machine parts @@ -177,7 +177,7 @@ whether it needs props/platform or not: | Term | What it is | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **host** | The agnostic core — `packages/core/*`. Declares what behavior is. | -| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `native`, `opentui`, …). | +| **target** | A substrate-specific bridge package and its render environment — `packages//*` (`react`, `solid`, `native`, `opentui`, …). | | **machine** | A state-graph config consumed by `machine()`; returns a startable service. | | **connect** | A function returning the logical surface a view spreads onto elements. | | **bindings** | The substrate-agnostic event + attr vocabulary — lives in `shared/bindings`, consumed by every target's normalize. Each target's rename map and drop set are vocabulary-typed (`HandlerTargets`/`AttrTargets`, `HandlerKey`/`AttrKey`), so a typo'd or unknown key is a compile error. | diff --git a/README.md b/README.md index 19362cc..a939b75 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,14 @@ transitions, same accessibility intent. Only the render differs. | pure behavior — no render | +---------------+--------------+ | connect() → onPress · role · describedBy - +---------------+---------------+ - v v v - +-----------+ +-----------+ +-----------+ - | React DOM | | Native | | TUI | - | → onClick | |→ Pressable| | → keypress| - | + aria-* | | + a11y | | + cells | - +-----------+ +-----------+ +-----------+ - same behavior, byte-for-byte — only the render differs + +---------------+---------------+---------------+ + v v v v + +-----------+ +-----------+ +-----------+ +-----------+ + | React DOM | | Solid | | Native | | TUI | + | → onClick | | → onClick | |→ Pressable| | → keypress| + | + aria-* | | + aria-* | | + a11y | | + cells | + +-----------+ +-----------+ +-----------+ +-----------+ + same behavior, byte-for-byte — only the render differs ``` > **Status: experimental.** The engine (`packages/core`) is stable and tested. The diff --git a/packages/solid/README.md b/packages/solid/README.md index 962fddf..66f2679 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -1,11 +1,36 @@ -# `@dunky.dev/state-machine-solid` +# `@dunky.dev/solid-state-machine` -The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). The -core engine is renderer-agnostic; this package is the thin Solid edge that drives -it: it builds the machine + connector, runs the Solid lifecycle, mirrors the -connector's snapshot into a fine-grained store, translates the agnostic -[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM -props, and owns the per-component substrate effects. +The **Solid bindings** for [`@dunky.dev/state-machine`](../core/README.md). + +The behavior lives in the core machine — plain TypeScript, no renderer. This +package is the thin Solid edge that runs it. It does four things: + +1. **`useMachine`** — build the machine once, run its lifecycle, mirror its + snapshot into a fine-grained store, run the component's platform effects. +2. **`useSelector`** — wake a leaf component only when one slice changes. +3. **`normalize`** — translate the machine's agnostic bindings (`onPress`, + `checked`) into real DOM props (`onClick`, `aria-checked`). +4. **`mergeProps`** — merge the consumer's props with the component's. + +``` + core (agnostic) + | + | config + connect() behavior + snapshot -> view api + | + v + this package (Solid) + | + | useMachine build + start the machine, subscribe + | | + | v + | api fine-grained store proxy + | | + | v + | normalize() DOM / ARIA / events + | + v + + +
I'm a tooltip
+
+ + ) +} +``` + +What happened: + +- `useMachine` built the machine and connector **once** (a Solid component body + runs a single time — the first props seeded the initial state), started it in + `onMount`, stops it in `onCleanup`. +- Hovering sends plain events; the machine handles the 300ms open delay itself + (`after`) — no `setTimeout` in the component. +- Reading `api.open` in JSX subscribed that spot to exactly that leaf — an + unrelated field changing never touches it. +- `normalize` turned `describedBy` into `aria-describedby` — the same `connect` + drives React, React Native, or a terminal through _their_ `normalize`. + +That's the whole model. Everything below is reference. --- -## `useMachine` — the one bridge hook +## `useMachine` — the bridge hook Every component's generated `useXxxApi` calls this with the agnostic pieces: @@ -64,7 +159,7 @@ Returns `{ api, machine }`: `api` is the reactive store to spread onto elements; --- -## `ComponentEffect` — substrate transport, without the boilerplate +## `ComponentEffect` — platform effects, next to the component 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 @@ -76,7 +171,7 @@ Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`) — and run unchanged on React and Solid: ```ts -import type { ComponentEffect } from '@dunky.dev/state-machine-solid' +import type { ComponentEffect } from '@dunky.dev/solid-state-machine' type TooltipEffect = ComponentEffect @@ -109,7 +204,7 @@ every other target. --- -## `useSelector` — fine-grained leaf subscription +## `useSelector` — fine-grained subscription Returns a Solid **accessor** that updates only when one slice of the machine changes: @@ -149,24 +244,20 @@ thousands of rows backed by one machine, each waking only for its own value const domProps = normalize(api.triggerProps) // { onClick, 'aria-expanded', role, tabindex, ... } ``` -Same vocabulary as the React DOM normalizer, with Solid's JSX conventions: - -| Agnostic binding | Solid DOM prop | -| ---------------- | ------------------------------------- | -| `onPress` | `onClick` | -| `onValueChange` | `onInput` (wrapped → `ChangePayload`) | -| `onDoublePress` | `onDblClick` | -| `focusable` | `tabindex` (`true → 0`, `false → -1`) | +Same vocabulary as the +[React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props), +with Solid's JSX conventions where the DOM prop name differs: `onValueChange` → +`onInput`, `onDoublePress` → `onDblClick`, and `focusable` → lowercase +`tabindex` (`true → 0`, `false → -1`). +[Check out the full mapping here](./src/normalize.ts). -Pointer/keyboard/focus handlers and the full ARIA attribute set map exactly as in -the [React DOM normalizer](../react/README.md#normalize--agnostic-bindings--dom-props). `undefined` values are dropped; any key not in the map (`class`, `data-*`) passes through unchanged. `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` are wrapped so the consumer receives the agnostic payload built from the native DOM event. --- -## `mergeProps` — combine consumer props with the component's props +## `mergeProps` — consumer props + component props When a consumer spreads their own props onto the same element the component controls, `mergeProps(consumer, library)` merges them the Radix/Ark way, Solid @@ -200,6 +291,20 @@ const finalProps = mergeProps(consumerProps, normalize(api.triggerProps)) | `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice; returns a Solid accessor (`O(readers)`) | | `normalize(bindings)` | agnostic bindings → Solid DOM/ARIA props | | `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `class` concat; `style` object merge) | -| `ComponentEffect` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps | -| `ComponentEffects` | `ComponentEffect[]` — a component's effect list | +| `ComponentEffect` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one platform effect + its prop deps; pass a static list of them | | `Bindings` | `Record` — the loose shape `normalize` accepts | + +--- + +## Solid version support + +Peer range: `solid-js` `^1.6` — the 1.x line, which is what npm's `latest` +still serves. Solid 2.0 (a release candidate as of August 2026) removes the +exact surface this bridge is built on — `solid-js/store`, single-argument +`createEffect`, `onMount`, the 1.x `reconcile` calling convention — so one code +path cannot serve both majors, and an open peer range would install-but-crash +on 2.0. Like the rest of the Solid ecosystem (`@solidjs/router`, TanStack, +`solid-primitives`), 2.0 support lands as a separate major once 2.0 is stable. +The migration is mapped: `createProjection` replaces the `createStore` + +`reconcile` mirror, `createEffect(compute, apply)` replaces the single-arg +form, and `onSettled` replaces `onMount`. diff --git a/tsconfig.json b/tsconfig.json index 864c273..967cf8e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "./tsconfig/base.json", + "extends": "./tsconfig/base.json" } diff --git a/website/src/content/docs/api/effects.mdx b/website/src/content/docs/api/effects.mdx index 5e4828c..6c26bb4 100644 --- a/website/src/content/docs/api/effects.mdx +++ b/website/src/content/docs/api/effects.mdx @@ -45,7 +45,7 @@ effects: [ ] ``` -**`ComponentEffect`:** needs the DOM or reads a prop. Declared outside the machine, passed to `useMachine` in your React component. A `ComponentEffect` is a `[fn, deps]` tuple: the function gets the running machine and current props, and `deps` names the props it reads so the bridge re-runs it only when those change: +**`ComponentEffect`:** needs the DOM or reads a prop. Declared outside the machine, passed to `useMachine` in your component (the tuple shape is the same on every target). A `ComponentEffect` is a `[fn, deps]` tuple: the function gets the running machine and current props, and `deps` names the props it reads so the bridge re-runs it only when those change: ```ts import { type ComponentEffect } from '@dunky.dev/react-state-machine' @@ -64,7 +64,8 @@ const onEscapeKey: ComponentEffect = [ ['closeOnEscape'], // re-run only when this prop changes ] -// list length must be stable across renders (one hook per entry) +// keep the list a module constant (on React each entry becomes a hook, +// so the length must be stable across renders) export const disclosureEffects = [onEscapeKey] ``` @@ -87,7 +88,7 @@ function Disclosure(props: DisclosureProps) { } ``` -The machine only sees `send({ type: 'close' })`; it has no idea an Escape key exists. On React Native the machine is unchanged; the effect swaps `keydown` for `BackHandler`. +The machine only sees `send({ type: 'close' })`; it has no idea an Escape key exists. On React Native the machine is unchanged; the effect swaps `keydown` for `BackHandler`. On [Solid](/libs/solid) the same tuple runs as its own `createEffect` — no rules-of-hooks constraint, but the list stays a module constant by convention. ## Named effects diff --git a/website/src/content/docs/libs/solid.mdx b/website/src/content/docs/libs/solid.mdx index 7463cc3..3dc840f 100644 --- a/website/src/content/docs/libs/solid.mdx +++ b/website/src/content/docs/libs/solid.mdx @@ -5,7 +5,9 @@ description: Solid bindings for @dunky.dev/state-machine. import Install from '../../../components/install.astro' - + + +Supports `solid-js` `^1.6` (the 1.x line). Solid 2.0 — currently a release candidate — reworks the reactivity surface this bridge uses, so 2.0 support will ship as a separate major once 2.0 is stable. The Solid package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Solid: lifecycle, fine-grained reactivity, prop translation, and platform effects. **The machine itself is unchanged** — the same `createDialogConfig` and `connectDialog` that drive React run here. @@ -16,7 +18,8 @@ Unlike React, this is not a `useSyncExternalStore` bridge: the connector's snaps The one bridge hook. Every component calls it with the four agnostic pieces and gets back the view API as a reactive store: ```tsx -import { useMachine, normalize } from '@dunky.dev/state-machine-solid' +import { Show } from 'solid-js' +import { useMachine, normalize } from '@dunky.dev/solid-state-machine' import { createDialogConfig, connectDialog, dialogEffects } from './dialog' type DialogProps = { @@ -36,7 +39,9 @@ function Dialog(props: DialogProps) { return ( <> - {api.isOpen &&
Dialog content
} + +
Dialog content
+
) } @@ -50,7 +55,7 @@ Those three values are where the dialog's behavior actually lives, and none of i ```ts // dialog.ts: plain functions, no Solid -import type { Connect } from '@dunky.dev/state-machine' +import { setup, type Connect } from '@dunky.dev/state-machine' type State = 'closed' | 'open' type Context = { closeOnEscape: boolean } @@ -63,14 +68,15 @@ type Api = { // 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' } }, - }, -}) +export const createDialogConfig = (props: DialogProps) => + setup.infer().createMachine({ + initial: props.open ? 'open' : 'closed', // props seed the machine ONCE + context: { closeOnEscape: props.closeOnEscape ?? true }, + states: { + closed: { on: { open: { target: 'open' } } }, + open: { on: { close: { target: 'closed' } } }, + }, + }) // connectDialog: a pure connect() that turns a machine snapshot into the view API // your JSX spreads. `isOpen`, `triggerProps`, `contentProps` come from here. @@ -104,18 +110,7 @@ normalize(api.triggerProps) // { onClick, 'aria-expanded', role, tabindex, ... } ``` -| Binding | DOM prop | -| ----------------------------------------------- | ------------------------------------------------------------------- | -| `onPress` | `onClick` | -| `onValueChange` | `onInput` (payload adapted to `ChangePayload`) | -| `onDoublePress` | `onDblClick` | -| `onPointerEnter/Leave/Move/Down/Up/Cancel` | same name | -| `onFocus` / `onBlur` / `onKeyDown` / `onKeyUp` | same name | -| `onWheel` / `onScroll` / `onScrollEnd` | same name (payload adapted to `WheelPayload` / `ScrollPayload`) | -| `describedBy` / `labelledBy` | `aria-describedby` / `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 machine binding maps handlers (`onPress` → `onClick`), ARIA props (`describedBy` → `aria-describedby`), ARIA state (`checked` → `aria-checked`), and focus (`focusable` → `tabindex`). [Check out the full mapping here](https://github.com/dunky-dev/state-machine/blob/main/packages/solid/src/normalize.ts). The differences from the [React](/libs/react) DOM normalizer are Solid's JSX conventions: `onValueChange → onInput` (Solid forwards native input events), `onDoublePress → onDblClick`, and `focusable → tabindex` (lowercase, the real attribute). `undefined` values are dropped; unknown keys (`class`, `data-*`) pass through unchanged. @@ -126,7 +121,9 @@ A few handlers whose agnostic payload differs from the raw event (`onValueChange When a consumer spreads their own props onto the same element the component controls: ```tsx - {api.open && ( -
api.setOpen(false)}> -
e.stopPropagation()}> +
api.setOpen(false)}> +
e.stopPropagation()}> -
    - {api.results.length === 0 &&
  • No results
  • } +
      + {api.results.length === 0 &&
    • No results
    • } {api.results.map((command, index) => { const itemProps = normalize(api.parts.getItemProps(command, index)) const selected = command.id === api.activeId @@ -67,10 +68,10 @@ export function CommandPalette(props: CommandPaletteProps) {
    • {command.label} - {command.hint && {command.hint}} + {command.hint && {command.hint}}
    • ) })} @@ -81,70 +82,3 @@ export function CommandPalette(props: CommandPaletteProps) {
) } - -const styles: Record = { - trigger: { - display: 'flex', - justifyContent: 'space-between', - minWidth: 300, - alignItems: 'center', - gap: 8, - padding: '10px 14px', - fontSize: 14, - color: '#5b6172', - background: '#fff', - border: '1px solid rgba(13,15,22,0.12)', - borderRadius: 10, - cursor: 'pointer', - }, - kbd: { - fontFamily: 'ui-monospace, monospace', - fontSize: 11, - color: '#8990a0', - background: 'rgba(13,15,22,0.05)', - border: '1px solid rgba(13,15,22,0.08)', - borderRadius: 6, - padding: '2px 6px', - }, - backdrop: { - position: 'fixed', - inset: 0, - background: 'rgba(13,15,22,0.35)', - display: 'flex', - justifyContent: 'center', - alignItems: 'flex-start', - paddingTop: '14vh', - }, - panel: { - width: 'min(560px, 92vw)', - background: '#fff', - borderRadius: 14, - boxShadow: '0 24px 64px rgba(13,15,22,0.28)', - overflow: 'hidden', - }, - input: { - width: '100%', - minWidth: 300, - boxSizing: 'border-box', - padding: '18px 20px', - fontSize: 16, - border: 'none', - borderBottom: '1px solid rgba(13,15,22,0.08)', - outline: 'none', - borderTopLeftRadius: 8, - borderTopRightRadius: 8, - }, - list: { listStyle: 'none', margin: 0, padding: 8, maxHeight: 320, overflowY: 'auto' }, - item: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: '10px 12px', - borderRadius: 8, - fontSize: 14, - color: '#1c1e26', - cursor: 'pointer', - }, - itemActive: { background: 'rgba(91,115,255,0.12)', color: '#3142c4' }, - empty: { padding: '16px 12px', color: '#8990a0', fontSize: 14 }, -} diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 16168da..4afae39 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -2,6 +2,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './app' +// The stylesheet both web sandboxes share. +import '../../shared/styles.css' + const root = document.getElementById('root') if (!root) throw new Error('missing #root') diff --git a/sandbox/shared/styles.css b/sandbox/shared/styles.css new file mode 100644 index 0000000..0586f67 --- /dev/null +++ b/sandbox/shared/styles.css @@ -0,0 +1,128 @@ +/* The one stylesheet the React and Solid apps share — same classes, same + look; each app only supplies markup. The non-DOM targets (opentui, native) + style natively and don't use this. */ + +/* Page */ + +.demo { + min-height: 100vh; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: #1c1e26; + background: linear-gradient(180deg, #eef1f6 0%, #ffffff 60%); +} + +.demo-title { + margin: 0; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.demo-lead { + margin: 0; + max-width: 460px; + text-align: center; + color: #5b6172; + line-height: 1.6; +} + +.demo-hint { + margin: 0; + color: #8990a0; + font-size: 16px; +} + +/* Palette */ + +.cmdk-trigger { + display: flex; + justify-content: space-between; + min-width: 300px; + align-items: center; + gap: 8px; + padding: 10px 14px; + font-size: 14px; + color: #5b6172; + background: #fff; + border: 1px solid rgba(13, 15, 22, 0.12); + border-radius: 10px; + cursor: pointer; +} + +.cmdk-kbd { + font-family: ui-monospace, monospace; + font-size: 11px; + color: #8990a0; + background: rgba(13, 15, 22, 0.05); + border: 1px solid rgba(13, 15, 22, 0.08); + border-radius: 6px; + padding: 2px 6px; +} + +.cmdk-backdrop { + position: fixed; + inset: 0; + background: rgba(13, 15, 22, 0.35); + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: 14vh; +} + +.cmdk-panel { + width: min(560px, 92vw); + background: #fff; + border-radius: 14px; + box-shadow: 0 24px 64px rgba(13, 15, 22, 0.28); + overflow: hidden; +} + +.cmdk-input { + width: 100%; + min-width: 300px; + box-sizing: border-box; + padding: 18px 20px; + font-size: 16px; + border: none; + border-bottom: 1px solid rgba(13, 15, 22, 0.08); + outline: none; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.cmdk-list { + list-style: none; + margin: 0; + padding: 8px; + max-height: 320px; + overflow-y: auto; +} + +.cmdk-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-radius: 8px; + font-size: 14px; + color: #1c1e26; + cursor: pointer; +} + +.cmdk-item.is-active { + background: rgba(91, 115, 255, 0.12); + color: #3142c4; +} + +.cmdk-empty { + padding: 16px 12px; + color: #8990a0; + font-size: 14px; +} diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 7f0d9f0..22609ab 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -1,5 +1,4 @@ import { createSignal } from 'solid-js' -import type { JSX } from '@solidjs/web' import { DEMO_COMMANDS } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' @@ -7,8 +6,8 @@ export function App() { const [last, setLast] = createSignal('—') return ( -
-

⌘K Command Palette

+
+

⌘K Command Palette



-

+

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

-

+

Last selected: {last()}

) } - -const styles: Record = { - main: { - 'min-height': '100vh', - margin: 0, - display: 'flex', - 'flex-direction': 'column', - 'align-items': 'center', - 'justify-content': 'center', - gap: '16px', - padding: '24px', - 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - color: '#1c1e26', - background: 'linear-gradient(180deg, #eef1f6 0%, #ffffff 60%)', - }, - title: { margin: 0, 'font-size': '28px', 'font-weight': 700, 'letter-spacing': '-0.02em' }, - lead: { - margin: 0, - 'max-width': '460px', - 'text-align': 'center', - color: '#5b6172', - 'line-height': 1.6, - }, - hint: { margin: 0, color: '#8990a0', 'font-size': '16px' }, -} diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx index 3a33f8e..74a5b1b 100644 --- a/sandbox/solid/src/command-palette.tsx +++ b/sandbox/solid/src/command-palette.tsx @@ -1,5 +1,4 @@ import { createEffect, For, Show } from 'solid-js' -import type { JSX } from '@solidjs/web' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' import { commandPaletteMachineConfig, @@ -25,7 +24,9 @@ const cmdkShortcut: ComponentEffect ] // The DOM renderer — zero interaction logic; `useMachine` runs the shared -// machine and `normalize` maps the logical bindings to DOM props. +// machine and `normalize` maps the logical bindings to DOM props. The +// component is just markup; the look lives in the stylesheet shared with the +// React app. export function CommandPalette(props: CommandPaletteProps) { const { api } = useMachine( commandPaletteMachineConfig, @@ -49,36 +50,33 @@ export function CommandPalette(props: CommandPaletteProps) { return (
- -
api.setOpen(false)}> -
e.stopPropagation()}> +
api.setOpen(false)}> +
e.stopPropagation()}> (inputEl = el)} {...normalize(api.parts.input)} value={api.query} placeholder='Type a command…' - style={styles.input} + class='cmdk-input' /> -
    +
      -
    • No results
    • +
    • No results
    • {(command, index) => { const itemProps = () => normalize(api.parts.getItemProps(command, index())) const selected = () => command.id === api.activeId return ( -
    • +
    • {command.label} - {command.hint} + {command.hint}
    • ) @@ -91,76 +89,3 @@ export function CommandPalette(props: CommandPaletteProps) {
) } - -const styles: Record = { - trigger: { - display: 'flex', - 'justify-content': 'space-between', - 'min-width': '300px', - 'align-items': 'center', - gap: '8px', - padding: '10px 14px', - 'font-size': '14px', - color: '#5b6172', - background: '#fff', - border: '1px solid rgba(13,15,22,0.12)', - 'border-radius': '10px', - cursor: 'pointer', - }, - kbd: { - 'font-family': 'ui-monospace, monospace', - 'font-size': '11px', - color: '#8990a0', - background: 'rgba(13,15,22,0.05)', - border: '1px solid rgba(13,15,22,0.08)', - 'border-radius': '6px', - padding: '2px 6px', - }, - backdrop: { - position: 'fixed', - inset: 0, - background: 'rgba(13,15,22,0.35)', - display: 'flex', - 'justify-content': 'center', - 'align-items': 'flex-start', - 'padding-top': '14vh', - }, - panel: { - width: 'min(560px, 92vw)', - background: '#fff', - 'border-radius': '14px', - 'box-shadow': '0 24px 64px rgba(13,15,22,0.28)', - overflow: 'hidden', - }, - input: { - width: '100%', - 'min-width': '300px', - 'box-sizing': 'border-box', - padding: '18px 20px', - 'font-size': '16px', - border: 'none', - 'border-bottom': '1px solid rgba(13,15,22,0.08)', - outline: 'none', - 'border-top-left-radius': '8px', - 'border-top-right-radius': '8px', - }, - list: { - 'list-style': 'none', - margin: 0, - padding: '8px', - 'max-height': '320px', - 'overflow-y': 'auto', - }, - item: { - display: 'flex', - 'justify-content': 'space-between', - 'align-items': 'center', - padding: '10px 12px', - 'border-radius': '8px', - 'font-size': '14px', - color: '#1c1e26', - cursor: 'pointer', - }, - itemActive: { background: 'rgba(91,115,255,0.12)', color: '#3142c4' }, - empty: { padding: '16px 12px', color: '#8990a0', 'font-size': '14px' }, -} diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index 51039a1..5d71ac8 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -1,6 +1,9 @@ import { render } from '@solidjs/web' import { App } from './app' +// The stylesheet both web sandboxes share. +import '../../shared/styles.css' + const root = document.getElementById('root') if (!root) throw new Error('missing #root') diff --git a/website/src/content/docs/libs/solid.mdx b/website/src/content/docs/libs/solid.mdx index f5ee211..7ef55e8 100644 --- a/website/src/content/docs/libs/solid.mdx +++ b/website/src/content/docs/libs/solid.mdx @@ -66,8 +66,6 @@ type Api = { 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) => setup.infer().createMachine({ initial: props.open ? 'open' : 'closed', // props seed the machine ONCE @@ -78,9 +76,6 @@ export const createDialogConfig = (props: DialogProps) => }, }) -// connectDialog: a pure connect() that turns a machine snapshot into the view API -// your JSX spreads. `isOpen`, `triggerProps`, `contentProps` come from here. -// The type args are . export const connectDialog: Connect = ({ state, send, @@ -93,9 +88,6 @@ export const connectDialog: Connect = ( 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] ``` @@ -112,10 +104,6 @@ normalize(api.triggerProps) The machine binding maps handlers (`onPress` → `onClick`), ARIA props (`describedBy` → `aria-describedby`), ARIA state (`checked` → `aria-checked`), and focus (`focusable` → `tabindex`). [Check out the full mapping here](https://github.com/dunky-dev/state-machine/blob/main/packages/solid/src/normalize.ts). -The differences from the [React](/libs/react) DOM normalizer are Solid's JSX conventions: `onValueChange → onInput` (Solid forwards native input events), `onDoublePress → onDblClick`, and `focusable → tabindex` (lowercase, the real attribute). `undefined` values are dropped; unknown keys (`class`, `data-*`) pass through unchanged. - -A few handlers whose agnostic payload differs from the raw event (`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer receives the agnostic payload, built from the native DOM event. - ## `mergeProps`: consumer + component props When a consumer spreads their own props onto the same element the component controls: @@ -190,7 +178,3 @@ export const dialogEffects = [onEscapeKey] The tuple shape is **identical to every other target** — the same `./dialog` effects run on React and Solid. The deps are prop NAMES (typed `(keyof Props)[]`, so a typo is a compile error); the Solid bridge reads exactly those props inside the effect's `createEffect`, so Solid's auto-tracking re-subscribes only when one of them actually changes. The machine only receives `send({ type: 'close' })`; it has no idea a keyboard event exists. See [Effects](/api/effects) for the full mental model of where each type of effect lives. - -## Same machine, every target - -`createDialogConfig` and `connectDialog` are written once and shared across [React](/libs/react), Solid, the [terminal](/libs/opentui), and [React Native](/libs/react-native) — the React Native version of this same dialog swaps the DOM `keydown` listener for a `BackHandler`, machine unchanged. Only the bridge (`useMachine`), `normalize`, and the lifecycle wiring differ between targets. From b356d9d06ec195b9e17abba470d91e0470ac0e84 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 17:52:02 +0200 Subject: [PATCH 14/21] style(sandbox): rejoin the lead line oxfmt wraps Co-Authored-By: Claude Fable 5 --- sandbox/solid/src/app.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 22609ab..3d9110c 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -20,8 +20,7 @@ export function App() {

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

Last selected: {last()} From 5710563cca21957beb49c6c576346a4211a3c70c Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 17:55:38 +0200 Subject: [PATCH 15/21] refactor(sandbox): move the shared stylesheet into shared/src Co-Authored-By: Claude Fable 5 --- sandbox/README.md | 2 +- sandbox/react/src/main.tsx | 2 +- sandbox/shared/{ => src}/styles.css | 0 sandbox/solid/src/main.tsx | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename sandbox/shared/{ => src}/styles.css (100%) diff --git a/sandbox/README.md b/sandbox/README.md index 077fcce..4f45dc5 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -10,7 +10,7 @@ produces. ``` sandbox/ +-- shared/ @sandbox/cmdk-core — the machine + connect() + commands (NO framework) -| + styles.css — the one stylesheet the React and Solid apps share +| + src/styles.css — the one stylesheet the React and Solid apps share +-- react/ Vite + React DOM → normalize → onClick / aria-* / role +-- solid/ Vite + Solid → normalize → onClick / aria-* / tabindex +-- opentui/ Bun + @opentui/react → normalize → onMouseDown / focusable / cells diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 4afae39..4950347 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client' import { App } from './app' // The stylesheet both web sandboxes share. -import '../../shared/styles.css' +import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') diff --git a/sandbox/shared/styles.css b/sandbox/shared/src/styles.css similarity index 100% rename from sandbox/shared/styles.css rename to sandbox/shared/src/styles.css diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index 5d71ac8..ed52a73 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -2,7 +2,7 @@ import { render } from '@solidjs/web' import { App } from './app' // The stylesheet both web sandboxes share. -import '../../shared/styles.css' +import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') From 89145febbaa14af9e9434c40528b6ec0c47167f6 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 14:38:55 +0200 Subject: [PATCH 16/21] fix(solid): stringify boolean aria-* values in normalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solid 2.0 renders a boolean attribute as presence/absence, so aria-expanded={false} disappeared and aria-modal={true} rendered empty. ARIA states are literal "true"/"false" tokens — serialize them explicitly. Found by the solid-dialog binding in dunky-dev/ui#44. Co-Authored-By: Claude Fable 5 --- packages/solid/src/normalize.ts | 14 +++++++++--- packages/solid/tests/normalize.test.ts | 30 ++++++++++++++------------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts index 7ee1930..5058d83 100644 --- a/packages/solid/src/normalize.ts +++ b/packages/solid/src/normalize.ts @@ -111,8 +111,8 @@ export const ATTR_MAP: AttrTargets = { // 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) + // widget state ('mixed' tristate and the aria-current / aria-invalid enums + // pass through as-is; booleans are stringified in normalize below) checked: 'aria-checked', pressed: 'aria-pressed', current: 'aria-current', @@ -168,7 +168,15 @@ export function normalize(logical: Bindings): Record { const attr = ATTR_MAP[key as AttrKey] if (attr) { - out[attr] = key === 'focusable' ? (value ? 0 : -1) : value + if (key === 'focusable') { + out[attr] = value ? 0 : -1 + } else if (typeof value === 'boolean' && attr.startsWith('aria-')) { + // Solid 2.0 treats a boolean attribute as presence/absence; ARIA + // states are literal "true"/"false" tokens, so serialize explicitly. + out[attr] = String(value) + } else { + out[attr] = value + } continue } diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts index 73fd8f7..7b24953 100644 --- a/packages/solid/tests/normalize.test.ts +++ b/packages/solid/tests/normalize.test.ts @@ -55,18 +55,20 @@ describe('solid normalize — attributes', () => { 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 }) + expect(normalize({ hasPopup: true })).toEqual({ 'aria-haspopup': 'true' }) }) - it('maps the boolean state attrs to their aria-* equivalents', () => { + // Booleans stringify: Solid 2.0 renders a boolean attribute as presence/ + // absence, but ARIA states are literal "true"/"false" tokens. + it('maps the boolean state attrs to their aria-* equivalents as string tokens', () => { 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, + 'aria-expanded': 'true', + 'aria-selected': 'false', + 'aria-disabled': 'true', + 'aria-hidden': 'false', + 'aria-modal': 'true', }) }) @@ -197,12 +199,12 @@ describe('solid normalize — expanded attribute surface', () => { }), ).toEqual({ 'aria-checked': 'mixed', - 'aria-pressed': true, + 'aria-pressed': 'true', 'aria-current': 'page', - 'aria-busy': true, + 'aria-busy': 'true', 'aria-invalid': 'spelling', - 'aria-required': true, - 'aria-readonly': false, + 'aria-required': 'true', + 'aria-readonly': 'false', }) }) @@ -248,8 +250,8 @@ describe('solid normalize — expanded attribute surface', () => { 'aria-orientation': 'horizontal', 'aria-sort': 'ascending', 'aria-autocomplete': 'list', - 'aria-multiline': true, - 'aria-multiselectable': false, + 'aria-multiline': 'true', + 'aria-multiselectable': 'false', 'aria-level': 2, 'aria-posinset': 3, 'aria-setsize': 10, @@ -265,7 +267,7 @@ describe('solid normalize — expanded attribute surface', () => { 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, + 'aria-atomic': 'true', }) }) From 43d8f932985a4cce46ba38a079007fbf4c46337b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 17:56:50 +0200 Subject: [PATCH 17/21] refactor(solid): shorten the reconcile-workaround comment --- packages/solid/src/use-machine.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index bc273f4..a6395f5 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -36,13 +36,9 @@ export function useMachine< const service = machine(createConfig(props)) const connection = connector(service, connect, { ...props }) - // Fine-grained mirror of the snapshot. Function leaves take a detour: - // solid-js 2.0.0-rc.0's reconcile corrupts a store node when it REPLACES a - // function-valued property (next tracked read halts reactivity), and - // connect() rebuilds every closure per wake. So reconcile sees the previous - // function identities (no-op) and the fresh ones land via plain draft - // writes, all in one setter. Remove the detour once fixed upstream. - // (The cast mirrors Solid's NoFn guard — a connect() api is never a function.) + // Workaround for a solid-js 2.0.0-rc.0 bug: reconcile corrupts a store + // node when it replaces a function prop. We reconcile with the old + // functions kept in place, then write the new functions in after. const [api, setApi] = createStore(connection.snapshot as Api extends Function ? never : Api) const off = connection.subscribe(() => { const next = connection.snapshot From ab3210ce561f6569591d5a52b72d92a45d74d3e0 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 18 Aug 2026 22:25:09 +0200 Subject: [PATCH 18/21] refactor(solid): rebind preventDefault with bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same behavior as the arrow-closure wrapper, one line — mirrors the react normalizer on #64. Co-Authored-By: Claude Fable 5 --- packages/solid/src/normalize.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts index 5058d83..cd129b7 100644 --- a/packages/solid/src/normalize.ts +++ b/packages/solid/src/normalize.ts @@ -79,8 +79,7 @@ const PAYLOAD_ADAPTERS: Record unknown> = { // Keep `this = event`: a detached native preventDefault throws "illegal invocation". function boundPreventDefault(e: AnyEvent): (() => void) | undefined { - const pd = e?.preventDefault - return pd && (() => pd.call(e)) + return e?.preventDefault?.bind(e) } function scrollPayload(e: AnyEvent): unknown { From 31c1c8614d58865059fbae6c8812b20506850059 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 16:23:39 +0200 Subject: [PATCH 19/21] feat(solid): target solid-js 2.0.0-rc.1 and drop the reconcile workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rc.1 fixes the store corruption when reconcile replaces a function-valued property, so the function-leaf detour (stableFunctionView / restoreFunctionLeaves) is gone — the bridge reconciles the snapshot directly. Peer range moves to ^2.0.0-rc.1; the regression test that guarded the detour now passes against the plain path. Co-Authored-By: Claude Fable 5 --- .changeset/solid-integration.md | 2 +- packages/solid/README.md | 2 +- packages/solid/package.json | 6 +- packages/solid/src/use-machine.ts | 86 ++----------------------- pnpm-lock.yaml | 68 +++++++++---------- pnpm-workspace.yaml | 4 ++ sandbox/solid/package.json | 4 +- website/src/content/docs/libs/solid.mdx | 2 +- 8 files changed, 51 insertions(+), 123 deletions(-) diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md index 00ca51e..92f294e 100644 --- a/.changeset/solid-integration.md +++ b/.changeset/solid-integration.md @@ -15,7 +15,7 @@ applies Solid's `class` concat + single-object `style` merge. The same `connect` and machine config run unchanged across React, Solid, React Native, and OpenTUI. Targets Solid 2.0 as a first-class citizen: the peer range is `solid-js` -`^2.0.0-rc.0`. Solid 1.x is not supported — 2.0 removed the surface a 1.x +`^2.0.0-rc.1`. Solid 1.x is not supported — 2.0 removed the surface a 1.x bridge would stand on (`solid-js/store`, single-argument `createEffect`, `onMount`) and 1.x lacks the root exports this package imports, so, like the rest of the Solid ecosystem (router, TanStack, solid-primitives), the majors diff --git a/packages/solid/README.md b/packages/solid/README.md index 7532ac9..300b6cc 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -300,7 +300,7 @@ const finalProps = mergeProps(consumerProps, normalize(api.triggerProps)) ## Solid version support -Peer range: `solid-js` `^2.0.0-rc.0` — Solid 2.0 is the first-class target. +Peer range: `solid-js` `^2.0.0-rc.1` — Solid 2.0 is the first-class target. Solid 1.x is NOT supported: 2.0 removed the exact surface this bridge is built on (`solid-js/store` moved into the root export, single-argument `createEffect` became `createEffect(compute, apply)`, `onMount` became `onSettled`), so one diff --git a/packages/solid/package.json b/packages/solid/package.json index f713f81..118bab9 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -40,13 +40,13 @@ "devDependencies": { "@dunky.dev/state-machine-bindings": "workspace:*", "@solidjs/testing-library": "^1.0.0-beta.2", - "@solidjs/web": "^2.0.0-rc.0", + "@solidjs/web": "^2.0.0-rc.1", "jsdom": "^29.1.1", - "solid-js": "^2.0.0-rc.0", + "solid-js": "^2.0.0-rc.1", "vite-plugin-solid": "^3.0.0-next.27", "vitest": "^4.1.7" }, "peerDependencies": { - "solid-js": "^2.0.0-rc.0" + "solid-js": "^2.0.0-rc.1" } } diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index a6395f5..844f393 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -36,17 +36,12 @@ export function useMachine< const service = machine(createConfig(props)) const connection = connector(service, connect, { ...props }) - // Workaround for a solid-js 2.0.0-rc.0 bug: reconcile corrupts a store - // node when it replaces a function prop. We reconcile with the old - // functions kept in place, then write the new functions in after. + // Fine-grained mirror of the snapshot: reading `api.x` subscribes to that + // leaf. (The cast mirrors Solid's NoFn guard — an api is never a function.) const [api, setApi] = createStore(connection.snapshot as Api extends Function ? never : Api) - const off = connection.subscribe(() => { - const next = connection.snapshot - setApi(draft => { - reconcile(stableFunctionView(next, draft) as Api)(draft) - restoreFunctionLeaves(draft as Record, next as Record) - }) - }) + const off = connection.subscribe(() => + setApi(reconcile(connection.snapshot as Api extends Function ? never : Api)), + ) onCleanup(off) // The compute spread reads every prop, so any consumed prop change re-runs @@ -75,74 +70,3 @@ export function useMachine< return { api, machine: service } } - -type AnyRecord = Record - -// Plain data only — the shapes reconcile recurses into; anything else is a -// leaf value to a store. -function isPlainData(value: unknown): value is AnyRecord { - if (value === null || typeof value !== 'object') return false - if (Array.isArray(value)) return true - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - -// Copy-on-write view of `next` with each function leaf swapped for the one the -// store already holds (reconcile no-ops on it; restoreFunctionLeaves writes the -// fresh identity). Function leaves with no counterpart are dropped from the -// view. Subtrees without functions are shared, not copied. -function stableFunctionView(next: unknown, prev: unknown): unknown { - if (!isPlainData(next)) return next - if (Array.isArray(next)) { - let copy: unknown[] | undefined - for (let i = 0; i < next.length; i++) { - const item = next[i] - let stable = item - if (typeof item === 'function') { - const before = Array.isArray(prev) ? (prev as unknown[])[i] : undefined - stable = typeof before === 'function' ? before : null - } else if (isPlainData(item)) { - stable = stableFunctionView(item, Array.isArray(prev) ? (prev as unknown[])[i] : undefined) - } - if (stable !== item) { - if (!copy) copy = next.slice() - copy[i] = stable - } - } - return copy ?? next - } - const before = isPlainData(prev) && !Array.isArray(prev) ? prev : undefined - let copy: AnyRecord | undefined - for (const key in next) { - const item = next[key] - if (typeof item === 'function') { - if (!copy) copy = { ...next } - const held = before?.[key] - if (typeof held === 'function') copy[key] = held - else delete copy[key] - continue - } - const stable = stableFunctionView(item, before?.[key]) - if (stable !== item) { - if (!copy) copy = { ...next } - copy[key] = stable - } - } - return copy ?? next -} - -// Assign the snapshot's function leaves into the draft — same setter as the -// reconcile, so the intermediate state is never observable. -function restoreFunctionLeaves(draft: AnyRecord, next: AnyRecord): void { - for (const key in next) { - const item = next[key] - if (typeof item === 'function') { - draft[key] = item - } else if (isPlainData(item)) { - const slot = draft[key] - if (slot !== null && typeof slot === 'object') { - restoreFunctionLeaves(slot as AnyRecord, item) - } - } - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daeffdb..bc2f50b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,19 +203,19 @@ importers: version: link:../shared/bindings '@solidjs/testing-library': specifier: ^1.0.0-beta.2 - version: 1.0.0-beta.2(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0) + version: 1.0.0-beta.2(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1) '@solidjs/web': - specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.0(solid-js@2.0.0-rc.0) + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1(solid-js@2.0.0-rc.1) jsdom: specifier: ^29.1.1 version: 29.1.1 solid-js: - specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.0 + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1 vite-plugin-solid: specifier: ^3.0.0-next.27 - version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.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)) + version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.7 version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -361,18 +361,18 @@ importers: specifier: workspace:^ version: link:../shared '@solidjs/web': - specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.0(solid-js@2.0.0-rc.0) + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1(solid-js@2.0.0-rc.1) solid-js: - specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.0 + specifier: ^2.0.0-rc.1 + version: 2.0.0-rc.1 devDependencies: vite: specifier: ^8.0.14 version: 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) vite-plugin-solid: specifier: ^3.0.0-next.27 - version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.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)) + version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) website: dependencies: @@ -2986,8 +2986,8 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@solidjs/signals@2.0.0-rc.0': - resolution: {integrity: sha512-oKZSfvsCcKw1uJjOGbUkJ+OqlhXLHtZ+rShSyu9KH0lUH7UUwfMfsKeh81JPiQxDDg4YLhEwI38hg0JkwzTdvA==} + '@solidjs/signals@2.0.0-rc.1': + resolution: {integrity: sha512-KQpgUbn9xuzFaXupwej9MvUnQV+H6wcCgvrERf+dygco3T9JWP9S02g/UoYwwmJ6Vh+LE1b82ZlSHYR2Bd1O8A==} '@solidjs/testing-library@1.0.0-beta.2': resolution: {integrity: sha512-TLhQ5IUT/fdDfqa4X2rkQWB28Y+zEwi6mK/TVTeiQlEHG63eK2jfgwNYf2NtQoPh2c3ihLilsCzxABiSTP3JoQ==} @@ -3007,10 +3007,10 @@ packages: '@testing-library/jest-dom': optional: true - '@solidjs/web@2.0.0-rc.0': - resolution: {integrity: sha512-pYSaA9+dH8H1h/d/ZF/P2kR6omfzFGNcdzKhWTcg9fJghXhn8+5UrXUr2iYxDdYNOXZzxxFQhYHSJ7P4HKDqgw==} + '@solidjs/web@2.0.0-rc.1': + resolution: {integrity: sha512-wLuxGtQUxaFfqxqhIUJGGSZB/upd3GzokQRFJKvO7biJGNZLAws+eanMqi0kK2Amg+MZZ7aVyPPKydf8mzdhkg==} peerDependencies: - solid-js: ^2.0.0-rc.0 + solid-js: ^2.0.0-rc.1 '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -6179,8 +6179,8 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - solid-js@2.0.0-rc.0: - resolution: {integrity: sha512-3enTJ71VL69nM5p/it2InVBDBt316Cqfij+F0S7VuHZLITc8YV7Rvavjoy52nAKKxYWXM+BcXh2K7KcLTf1zdQ==} + solid-js@2.0.0-rc.1: + resolution: {integrity: sha512-UD+UfqfiuuOTaDw01YeT+LwsYJC2ilTlMfs6h8EC8FFLmZD0ZjeZIoJXdZEo9uMzIof2tu0Rfh3dnzI7FAmuJQ==} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -9645,34 +9645,34 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@solidjs/signals@2.0.0-rc.0': {} + '@solidjs/signals@2.0.0-rc.1': {} - '@solidjs/testing-library@1.0.0-beta.2(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)': + '@solidjs/testing-library@1.0.0-beta.2(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)': dependencies: - '@solidjs/web': 2.0.0-rc.0(solid-js@2.0.0-rc.0) + '@solidjs/web': 2.0.0-rc.1(solid-js@2.0.0-rc.1) '@testing-library/dom': 10.4.1 - solid-js: 2.0.0-rc.0 + solid-js: 2.0.0-rc.1 - '@solidjs/vite-plugin@3.0.0-next.28(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.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))': + '@solidjs/vite-plugin@3.0.0-next.28(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 '@babel/core': 7.29.7 '@dom-expressions/compiler': 0.50.0-next.40 - '@solidjs/web': 2.0.0-rc.0(solid-js@2.0.0-rc.0) + '@solidjs/web': 2.0.0-rc.1(solid-js@2.0.0-rc.1) '@types/babel__core': 7.20.5 - babel-preset-solid: 2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.0) + babel-preset-solid: 2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.1) merge-anything: 5.1.7 - solid-js: 2.0.0-rc.0 + solid-js: 2.0.0-rc.1 vite: 8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vitefu: 1.1.3(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)) transitivePeerDependencies: - supports-color - '@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0)': + '@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1)': dependencies: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - solid-js: 2.0.0-rc.0 + solid-js: 2.0.0-rc.1 '@standard-schema/spec@1.1.0': {} @@ -10351,12 +10351,12 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) - babel-preset-solid@2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.0): + babel-preset-solid@2.0.0-rc.0(@babel/core@7.29.7)(solid-js@2.0.0-rc.1): dependencies: '@babel/core': 7.29.7 '@dom-expressions/babel-plugin-jsx': 0.50.0-next.42(@babel/core@7.29.7) optionalDependencies: - solid-js: 2.0.0-rc.0 + solid-js: 2.0.0-rc.1 bail@2.0.2: {} @@ -13940,9 +13940,9 @@ snapshots: smol-toml@1.6.1: {} - solid-js@2.0.0-rc.0: + solid-js@2.0.0-rc.1: dependencies: - '@solidjs/signals': 2.0.0-rc.0 + '@solidjs/signals': 2.0.0-rc.1 csstype: 3.2.3 seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) @@ -14375,9 +14375,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-solid@3.0.0-next.27(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.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)): + vite-plugin-solid@3.0.0-next.27(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - '@solidjs/vite-plugin': 3.0.0-next.28(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.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)) + '@solidjs/vite-plugin': 3.0.0-next.28(@solidjs/web@2.0.0-rc.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - '@solidjs/web' - '@testing-library/jest-dom' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f85ebdb..66765af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,7 @@ packages: allowBuilds: esbuild: true sharp: true +minimumReleaseAgeExclude: + - '@solidjs/signals@2.0.0-rc.1' + - '@solidjs/web@2.0.0-rc.1' + - solid-js@2.0.0-rc.1 diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json index 3759329..e9ac5b6 100644 --- a/sandbox/solid/package.json +++ b/sandbox/solid/package.json @@ -14,8 +14,8 @@ "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", "@sandbox/cmdk-core": "workspace:^", - "@solidjs/web": "^2.0.0-rc.0", - "solid-js": "^2.0.0-rc.0" + "@solidjs/web": "^2.0.0-rc.1", + "solid-js": "^2.0.0-rc.1" }, "devDependencies": { "vite": "^8.0.14", diff --git a/website/src/content/docs/libs/solid.mdx b/website/src/content/docs/libs/solid.mdx index 7ef55e8..13de5af 100644 --- a/website/src/content/docs/libs/solid.mdx +++ b/website/src/content/docs/libs/solid.mdx @@ -7,7 +7,7 @@ import Install from '../../../components/install.astro' -Targets Solid 2.0 (`solid-js` `^2.0.0-rc.0`) as a first-class citizen. Solid 1.x is not supported — 2.0 reworked the reactivity surface this bridge is built on, so, like the rest of the Solid ecosystem, the majors are version-split. +Targets Solid 2.0 (`solid-js` `^2.0.0-rc.1`) as a first-class citizen. Solid 1.x is not supported — 2.0 reworked the reactivity surface this bridge is built on, so, like the rest of the Solid ecosystem, the majors are version-split. The Solid package is a thin edge layer. Behavior lives in the core machine and the component's `connect` function; this package only adapts them to Solid: lifecycle, fine-grained reactivity, prop translation, and platform effects. **The machine itself is unchanged** — the same `createDialogConfig` and `connectDialog` that drive React run here. From d40b8e04a79922d3f57311236d2fcab2c37058f0 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 19 Aug 2026 19:45:40 +0200 Subject: [PATCH 20/21] test(solid): close the bridge's behavioral gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSelector: pin the compute-form seed (a selected function comes back by identity, never invoked — the createSignal function-arg hazard) and that a disposed owner stops evaluating its selector (onCleanup(off)). - useMachine: pin the stop half of the lifecycle (reactions stop firing after unmount), that a ComponentEffect's cleanup runs BEFORE its re-run on a dep change, and the negative half of the fine-grained claim (an unrelated field change does not wake a reader). - mergeProps: pin the undefined-consumer early return and the documented string-style fall-through to library-wins. Both regression guards were mutation-verified: reverting the seed to the value form and dropping onCleanup(off) each fail exactly their new test. Co-Authored-By: Claude Fable 5 --- packages/solid/tests/merge-props.test.ts | 10 +++++ packages/solid/tests/use-machine.test.tsx | 48 ++++++++++++++++++++-- packages/solid/tests/use-selector.test.tsx | 34 +++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/solid/tests/merge-props.test.ts b/packages/solid/tests/merge-props.test.ts index 0b121e0..baef81f 100644 --- a/packages/solid/tests/merge-props.test.ts +++ b/packages/solid/tests/merge-props.test.ts @@ -66,6 +66,16 @@ describe('solid mergeProps', () => { const out = mergeProps({ id: 'a' }, { class: 'x' }) expect(out.class).toBe('x') }) + + it('string consumer style falls through to library-wins (no merge)', () => { + const out = mergeProps({ style: 'color: red' }, { style: { color: 'blue' } }) + expect(out.style).toEqual({ color: 'blue' }) + }) + + it('returns the base merge when consumer is undefined', () => { + const out = mergeProps(undefined, { id: 'lib', class: 'x' }) + expect(out).toEqual({ id: 'lib', class: 'x' }) + }) }) describe('solid mergeProps typing', () => { diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx index c8c2b8e..47f5481 100644 --- a/packages/solid/tests/use-machine.test.tsx +++ b/packages/solid/tests/use-machine.test.tsx @@ -137,6 +137,14 @@ describe('useMachine — fine-grained store', () => { expect(getByTestId('open').textContent).toBe('y') expect(getByTestId('count').textContent).toBe('1') expect(countReads.mock.calls.length).toBeGreaterThan(countReadsBefore) + + // The negative half of the claim: open→closed flips `open` but leaves + // `count` untouched — the count reader must not re-run. + const countReadsAfterFirstToggle = countReads.mock.calls.length + api!.toggle() + flush() + expect(getByTestId('open').textContent).toBe('n') + expect(countReads.mock.calls.length).toBe(countReadsAfterFirstToggle) }) }) @@ -195,19 +203,26 @@ describe('useMachine — reactions follow the machine lifecycle', () => { api = useMachine(createConfig(), connect, noEffects, props).api return null } - render(() => ) + const { unmount } = render(() => ) expect(onOpenChange).not.toHaveBeenCalled() // not on subscribe api!.toggle() expect(onOpenChange).toHaveBeenCalledWith(true) api!.toggle() expect(onOpenChange).toHaveBeenCalledWith(false) + + // The stop half: unmount stops the machine, which unhooks the connector's + // reactions — a send may still transition, but the callback must not fire. + unmount() + api!.toggle() + expect(onOpenChange).toHaveBeenCalledTimes(2) }) }) describe('useMachine — function-valued api leaves', () => { - // Regression: solid-js 2.0.0-rc.0's reconcile halts reactivity when it - // replaces a function-valued property, and connect() rebuilds every closure - // per wake — the bridge routes function leaves around reconcile. + // Regression for a solid-js 2.0.0-rc.0 bug (fixed in rc.1): reconcile + // invoked a function-valued property instead of replacing it, corrupting + // it on the next wake — connect() rebuilds every closure per wake, so this + // hit any nested function leaf (e.g. parts.getItemProps) once read. type PartsApi = { open: boolean results: { id: string; label: string }[] @@ -297,6 +312,31 @@ describe('useMachine — component effects', () => { expect(fn).toHaveBeenCalledTimes(2) }) + it('runs the previous cleanup BEFORE re-running on a dep change', () => { + // The double-subscribe hazard: 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 effects: ComponentEffect[] = [[fn, ['label']]] + const [label, setLabel] = createSignal('a') + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + } + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(cleanup).not.toHaveBeenCalled() + + setLabel('b') + flush() + expect(cleanup).toHaveBeenCalledOnce() + expect(cleanup.mock.invocationCallOrder[0]!).toBeLessThan(fn.mock.invocationCallOrder[1]!) + }) + it('does NOT re-run when the effect body reads a prop outside its deps (untracked)', () => { // 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 hidden dependency. diff --git a/packages/solid/tests/use-selector.test.tsx b/packages/solid/tests/use-selector.test.tsx index 3b3c7fc..c9bb0cf 100644 --- a/packages/solid/tests/use-selector.test.tsx +++ b/packages/solid/tests/use-selector.test.tsx @@ -91,6 +91,25 @@ describe('useSelector — value-deduped accessor', () => { }) }) +describe('useSelector — function-typed selections', () => { + // Regression guard for the compute-form seed: Solid 2.0's createSignal + // treats a function first argument as a compute and CALLS it, so a selected + // callback passed as a plain value would be invoked and its return value + // stored. The accessor must hand back the function itself, by identity. + it('returns a selected function by identity, never invoking it', () => { + const m = makeMachine() + const handlers = [vi.fn(() => 'h0'), vi.fn(() => 'h1')] + const { result } = renderHook(() => useSelector(m, () => handlers[m.context.a]!)) + expect(result()).toBe(handlers[0]) + + m.send({ type: 'incA' }) + flush() + expect(result()).toBe(handlers[1]) + expect(handlers[0]).not.toHaveBeenCalled() + expect(handlers[1]).not.toHaveBeenCalled() + }) +}) + describe('useSelector — custom isEqual for object selections', () => { it('uses the provided isEqual to dedup an object selection', () => { const m = makeMachine() @@ -116,6 +135,21 @@ describe('useSelector — custom isEqual for object selections', () => { }) }) +describe('useSelector — subscription follows the owner lifecycle', () => { + it('stops evaluating the selector once the owner is disposed', () => { + const m = makeMachine() + const selector = vi.fn(() => m.context.a) + const { result, cleanup } = renderHook(() => useSelector(m, selector)) + expect(result()).toBe(0) + + cleanup() + const evaluations = selector.mock.calls.length + m.send({ type: 'incA' }) // disposed reader → the machine must not re-evaluate it + flush() + expect(selector.mock.calls.length).toBe(evaluations) + }) +}) + describe('useSelector — O(readers): a slice change wakes only its reader', () => { it('updates only the leaf whose selected slice changed', () => { const m = makeMachine() From 72ced27ffe19bbe9d83e8934e44f111b246b7be3 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 21 Aug 2026 15:35:10 +0200 Subject: [PATCH 21/21] refactor(dom): extract the shared DOM translation into @dunky.dev/state-machine-dom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aria-* attribute projection and the payload adapters were byte-identical in the React and Solid normalizers, and had already drifted once (the preventDefault bind fix landed in one but not the other). They now live once in packages/dom; each target keeps only what genuinely differs — its handler prop names (onChange/onDoubleClick vs onInput/onDblClick), the focusable → tabindex casing, and its value serialization (React passes ARIA booleans through, Solid stringifies them). Payload construction is pinned once in the dom package's tests; the target suites keep a wiring proof each (normalize wraps the handler with its adapter) plus the vocabulary accounting. No consumer-facing API change. Co-Authored-By: Claude Fable 5 --- .changeset/dom-shared-translation-package.md | 20 +++ packages/dom/LICENSE | 21 +++ packages/dom/package.json | 39 +++++ packages/dom/src/index.ts | 143 +++++++++++++++++ packages/dom/tests/payload-adapters.test.ts | 91 +++++++++++ packages/react/package.json | 1 + packages/react/src/normalize.ts | 145 ++--------------- packages/react/tests/normalize.test.ts | 67 +------- packages/solid/package.json | 1 + packages/solid/src/normalize.ts | 154 +++---------------- packages/solid/tests/normalize.test.ts | 67 +------- pnpm-lock.yaml | 12 ++ sandbox/react/vite.config.ts | 1 + sandbox/solid/vite.config.ts | 1 + tsdown.config.ts | 1 + 15 files changed, 370 insertions(+), 394 deletions(-) create mode 100644 .changeset/dom-shared-translation-package.md create mode 100644 packages/dom/LICENSE create mode 100644 packages/dom/package.json create mode 100644 packages/dom/src/index.ts create mode 100644 packages/dom/tests/payload-adapters.test.ts diff --git a/.changeset/dom-shared-translation-package.md b/.changeset/dom-shared-translation-package.md new file mode 100644 index 0000000..351adf7 --- /dev/null +++ b/.changeset/dom-shared-translation-package.md @@ -0,0 +1,20 @@ +--- +'@dunky.dev/state-machine-dom': minor +'@dunky.dev/react-state-machine': patch +'@dunky.dev/solid-state-machine': patch +--- + +Add `@dunky.dev/state-machine-dom` — the DOM half of the bindings translation, +shared by every DOM target. The `aria-*` attribute projection and the payload +adapters (`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` → neutral +payloads, with `preventDefault` bound to its event) were byte-identical in the +React and Solid normalizers; they now live once, in this package, and each +target keeps only what genuinely differs: its handler prop names +(`onChange`/`onDoubleClick` vs `onInput`/`onDblClick`), the `focusable` → +tabindex casing (`tabIndex` vs `tabindex`), and its value serialization +(React passes ARIA booleans through; Solid stringifies them). + +No API change for consumers of the React or Solid packages — `normalize` +behaves exactly as before; the shared package becomes a dependency of both. +The motivation is drift-proofing: a payload-adapter fix previously had to be +applied to each DOM target by hand, and had already diverged once. diff --git a/packages/dom/LICENSE b/packages/dom/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/dom/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/dom/package.json b/packages/dom/package.json new file mode 100644 index 0000000..ee4e6f4 --- /dev/null +++ b/packages/dom/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dunky.dev/state-machine-dom", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/dom" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine-bindings": "workspace:*" + } +} diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts new file mode 100644 index 0000000..9bac11e --- /dev/null +++ b/packages/dom/src/index.ts @@ -0,0 +1,143 @@ +import type { AttrTargets, HandlerTargets } from '@dunky.dev/state-machine-bindings' + +/** + * Handler names shared verbatim by every DOM target. The two divergent keys + * (`onValueChange`, `onDoublePress`) are deliberately absent — each target + * adds its own. + */ +export const DOM_HANDLER_MAP: HandlerTargets = { + onPress: 'onClick', + onPointerEnter: 'onPointerEnter', + onPointerLeave: 'onPointerLeave', + onPointerMove: 'onPointerMove', + onPointerDown: 'onPointerDown', + onPointerUp: 'onPointerUp', + onPointerCancel: 'onPointerCancel', + onFocus: 'onFocus', + onBlur: 'onBlur', + onKeyDown: 'onKeyDown', + onKeyUp: 'onKeyUp', + onContextMenu: 'onContextMenu', + onWheel: 'onWheel', + onScroll: 'onScroll', + onScrollEnd: 'onScrollEnd', +} + +/** + * The `aria-` projection of the logical attr vocabulary — pure DOM truth, + * identical in every DOM target. `focusable` is deliberately absent: its + * target prop differs in casing (React `tabIndex`, Solid `tabindex`). + */ +export const DOM_ATTR_MAP: AttrTargets = { + 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', + role: 'role', + id: 'id', + + // labeling + label: 'aria-label', + // widget state (values pass through untransformed here — booleans, the + // 'mixed' tristate, and the aria-current / aria-invalid enums; a target's + // normalize() may serialize further, e.g. Solid stringifies booleans) + 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', +} + +/** + * The DOM event fields the payload adapters read. React's synthetic events + * and Solid's native events expose the same names, so one shape serves both. + */ +export type AnyEvent = { + target?: { value?: unknown; checked?: unknown; type?: string } + currentTarget?: Record + deltaX?: number + deltaY?: number + deltaZ?: number + deltaMode?: number + defaultPrevented?: boolean + preventDefault?: () => void +} + +// DOM WheelEvent.deltaMode (0/1/2) → the neutral WheelPayload unit. +const WHEEL_UNIT = ['pixel', 'line', 'page'] as const + +/** + * Handlers whose agnostic payload differs from the raw DOM event, keyed by + * LOGICAL name. A target's normalize() wraps the consumer handler so it + * receives the neutral payload built here instead of the event. + */ +export 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: boundPreventDefault(e) } + }, + onWheel: e => ({ + deltaX: e?.deltaX, + deltaY: e?.deltaY, + deltaZ: e?.deltaZ, + deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel', + defaultPrevented: e?.defaultPrevented, + preventDefault: boundPreventDefault(e), + }), + onScroll: scrollPayload, + onScrollEnd: scrollPayload, +} + +// Keep `this = event`: a detached native preventDefault throws "illegal invocation". +function boundPreventDefault(e: AnyEvent): (() => void) | undefined { + return e?.preventDefault?.bind(e) +} + +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, + } +} diff --git a/packages/dom/tests/payload-adapters.test.ts b/packages/dom/tests/payload-adapters.test.ts new file mode 100644 index 0000000..5af6923 --- /dev/null +++ b/packages/dom/tests/payload-adapters.test.ts @@ -0,0 +1,91 @@ +/** + * The DOM payload adapters — pure-logic tests (no DOM runtime needed). + * + * Each adapter reads a native DOM event into the neutral payload shape the + * component vocabulary speaks (`ChangePayload`/`WheelPayload`/`ScrollPayload`). + * The targets' own tests cover the wiring (that normalize() wraps a handler + * with its adapter); the payload construction itself is pinned once, here. + */ +import { describe, expect, it } from 'vitest' +import { PAYLOAD_ADAPTERS, type AnyEvent } from '@dunky.dev/state-machine-dom' + +const adapt = (key: string, e: AnyEvent): Record => + PAYLOAD_ADAPTERS[key]!(e) as Record + +describe('dom payload adapters — onValueChange', () => { + it('reads text-like inputs from target.value', () => { + expect(adapt('onValueChange', { target: { value: 'hi', type: 'text' } })).toEqual({ + value: 'hi', + defaultPrevented: undefined, + preventDefault: undefined, + }) + }) + + it('reads checkbox/radio from target.checked (the boolean, not the value attr)', () => { + expect(adapt('onValueChange', { target: { checked: true, type: 'checkbox' } })).toMatchObject({ + value: true, + }) + expect( + adapt('onValueChange', { target: { checked: false, value: 'on', type: 'radio' } }), + ).toMatchObject({ value: false }) + }) +}) + +describe('dom payload adapters — preventDefault', () => { + it('binds payload.preventDefault to the event (a detached native method throws)', () => { + // Fake event whose preventDefault asserts its `this`, like a native Event does. + const makeEvent = (): AnyEvent => ({ + target: { value: 'x', type: 'text' }, + defaultPrevented: false, + preventDefault(this: { defaultPrevented: boolean }) { + this.defaultPrevented = true + }, + }) + for (const key of ['onValueChange', 'onWheel']) { + const event = makeEvent() + ;(adapt(key, event).preventDefault as () => void)() + expect(event.defaultPrevented).toBe(true) + } + }) +}) + +describe('dom payload adapters — onWheel', () => { + it('builds a WheelPayload with a neutral deltaUnit (deltaMode → enum)', () => { + expect(adapt('onWheel', { deltaX: 1, deltaY: 2, deltaZ: 0, deltaMode: 1 })).toMatchObject({ + deltaX: 1, + deltaY: 2, + deltaZ: 0, + deltaUnit: 'line', + }) + }) + + it('defaults deltaUnit to pixel when deltaMode is missing or out of range', () => { + expect(adapt('onWheel', {})).toMatchObject({ deltaUnit: 'pixel' }) + expect(adapt('onWheel', { deltaMode: 7 })).toMatchObject({ deltaUnit: 'pixel' }) + }) +}) + +describe('dom payload adapters — onScroll / onScrollEnd', () => { + it('build a neutral ScrollPayload from currentTarget geometry', () => { + const e: AnyEvent = { + currentTarget: { + scrollLeft: 5, + scrollTop: 50, + scrollWidth: 800, + scrollHeight: 1200, + clientWidth: 400, + clientHeight: 600, + }, + } + for (const key of ['onScroll', 'onScrollEnd']) { + expect(adapt(key, e)).toEqual({ + offsetX: 5, + offsetY: 50, + contentWidth: 800, + contentHeight: 1200, + viewportWidth: 400, + viewportHeight: 600, + }) + } + }) +}) diff --git a/packages/react/package.json b/packages/react/package.json index 9ddc5fa..72d8340 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@dunky.dev/state-machine": "workspace:*", + "@dunky.dev/state-machine-dom": "workspace:*", "@dunky.dev/state-machine-utils": "workspace:*" }, "devDependencies": { diff --git a/packages/react/src/normalize.ts b/packages/react/src/normalize.ts index 7c07326..3c08323 100644 --- a/packages/react/src/normalize.ts +++ b/packages/react/src/normalize.ts @@ -1,152 +1,25 @@ -/** - * Translate the machine layer's logical surface to React DOM props. - * - * Input keys are the substrate-agnostic vocabulary a connect() emits - * (`EventBindings` / `AttrBindings` in `@dunky.dev/state-machine-bindings`), - * which is ARIA-shaped by design — see `ACCESSIBILITY.md`. The DOM is the - * closest host to that vocabulary, so most attrs are a mechanical `aria-` - * prefix and nothing is dropped. The parts that aren't mechanical: - * - `onPress` → `onClick`: the DOM's activation event, which fires for - * keyboard Enter/Space on a native control too, not just a mouse press. - * - `focusable` → `tabIndex` 0 / -1, not a boolean — `false` still has to - * leave the element focusable in script. - * - `disabled` → `aria-disabled`, never the HTML `disabled` attribute: a - * disabled control stays in the tab order and keeps announcing itself, - * per APG. A consumer that wants the HTML attribute passes it themselves. - * - `onValueChange`/`onWheel`/`onScroll`/`onScrollEnd` also have their - * argument translated — the DOM event is read into the neutral payload - * shape (see PAYLOAD_ADAPTERS), never forwarded raw. - */ import type { AttrKey, AttrTargets, HandlerKey, HandlerTargets, } from '@dunky.dev/state-machine-bindings' +import { + DOM_ATTR_MAP, + DOM_HANDLER_MAP, + PAYLOAD_ADAPTERS, + type AnyEvent, +} from '@dunky.dev/state-machine-dom' export const HANDLER_MAP: HandlerTargets = { - onPress: 'onClick', - onPointerEnter: 'onPointerEnter', - onPointerLeave: 'onPointerLeave', - onPointerMove: 'onPointerMove', - onPointerDown: 'onPointerDown', - onPointerUp: 'onPointerUp', - onPointerCancel: 'onPointerCancel', - onFocus: 'onFocus', - onBlur: 'onBlur', - onKeyDown: 'onKeyDown', - onKeyUp: 'onKeyUp', - // onValueChange/onWheel/onScroll/onScrollEnd also have their argument translated (see PAYLOAD_ADAPTERS). + ...DOM_HANDLER_MAP, onValueChange: 'onChange', - onContextMenu: 'onContextMenu', onDoublePress: 'onDoubleClick', - onWheel: 'onWheel', - onScroll: 'onScroll', - onScrollEnd: 'onScrollEnd', -} - -// 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 - const value = t && (t.type === 'checkbox' || t.type === 'radio') ? t.checked : t?.value - return { value, defaultPrevented: e?.defaultPrevented, preventDefault: boundPreventDefault(e) } - }, - onWheel: e => ({ - deltaX: e?.deltaX, - deltaY: e?.deltaY, - deltaZ: e?.deltaZ, - deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel', - defaultPrevented: e?.defaultPrevented, - preventDefault: boundPreventDefault(e), - }), - onScroll: scrollPayload, - onScrollEnd: scrollPayload, -} - -// Keep `this = event`: a detached native preventDefault throws "illegal invocation". -function boundPreventDefault(e: AnyEvent): (() => void) | undefined { - return e?.preventDefault?.bind(e) -} - -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, - } } export const ATTR_MAP: AttrTargets = { - 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', + ...DOM_ATTR_MAP, 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 @@ -159,6 +32,8 @@ export function normalize(logical: Bindings): Record { const handler = HANDLER_MAP[key as HandlerKey] 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 } diff --git a/packages/react/tests/normalize.test.ts b/packages/react/tests/normalize.test.ts index fc2b3a7..d6d0ee4 100644 --- a/packages/react/tests/normalize.test.ts +++ b/packages/react/tests/normalize.test.ts @@ -138,72 +138,13 @@ describe('react normalize — expanded handler surface', () => { expect(out.onDoubleClick).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.onChange as (e: unknown) => void)({ target: { value: 'hi', type: 'text' } }) - expect(onValueChange).toHaveBeenCalledWith({ - value: 'hi', - defaultPrevented: undefined, - preventDefault: undefined, - }) - ;(out.onChange as (e: unknown) => void)({ target: { checked: true, type: 'checkbox' } }) - expect(onValueChange).toHaveBeenLastCalledWith(expect.objectContaining({ value: true })) - }) - - it('binds payload.preventDefault to the event (a detached native method throws)', () => { - const onValueChange = vi.fn() - const onWheel = vi.fn() - const out = normalize({ onValueChange, onWheel }) - // Fake event whose preventDefault asserts its `this`, like a native Event does. - const makeEvent = () => ({ - target: { value: 'x', type: 'text' }, - defaultPrevented: false, - preventDefault(this: { defaultPrevented: boolean }) { - this.defaultPrevented = true - }, - }) - const changeEvent = makeEvent() - ;(out.onChange as (e: unknown) => void)(changeEvent) - ;(onValueChange.mock.calls[0]![0] as { preventDefault: () => void }).preventDefault() - expect(changeEvent.defaultPrevented).toBe(true) - - const wheelEvent = makeEvent() - ;(out.onWheel as (e: unknown) => void)(wheelEvent) - ;(onWheel.mock.calls[0]![0] as { preventDefault: () => void }).preventDefault() - expect(wheelEvent.defaultPrevented).toBe(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' })) }) }) diff --git a/packages/solid/package.json b/packages/solid/package.json index 118bab9..7dd1d4f 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@dunky.dev/state-machine": "workspace:*", + "@dunky.dev/state-machine-dom": "workspace:*", "@dunky.dev/state-machine-utils": "workspace:*" }, "devDependencies": { diff --git a/packages/solid/src/normalize.ts b/packages/solid/src/normalize.ts index cd129b7..05fba23 100644 --- a/packages/solid/src/normalize.ts +++ b/packages/solid/src/normalize.ts @@ -1,14 +1,17 @@ /** - * Translate the machine layer's logical surface (ARIA-shaped, see - * ACCESSIBILITY.md) to Solid DOM props. Mostly a mechanical `aria-` prefix; - * the exceptions: - * - `onPress` → `onClick` (the DOM activation event, incl. keyboard Enter/Space) - * - `focusable` → `tabindex` 0/-1 (`false` must stay script-focusable) - * - `disabled` → `aria-disabled` (stays in tab order + announces, per APG) - * - `onValueChange`/`onWheel`/`onScroll(End)` payloads are adapted, never raw - * Vs the React normalizer: Solid handlers receive NATIVE events, - * `onValueChange` lands on `onInput` (Solid's `onChange` fires on commit), and - * `tabindex` is lowercase. + * Translate the machine layer's logical surface to Solid DOM props. + * + * The DOM-shared half — the `aria-` attr projection and the payload adapters + * — lives in `@dunky.dev/state-machine-dom` (see its header for the shared + * decisions). This file adds only what is Solid's own: + * - `onValueChange` → `onInput`: Solid's per-change event (Solid's `onChange` + * fires only on commit). Handlers receive NATIVE events, not synthetics — + * the shared adapters read the same field names either way. + * - `onDoublePress` → `onDblClick` (Solid's DOM-cased prop). + * - `focusable` → `tabindex` 0 / -1 — lowercase (the real attribute), and not + * a boolean: `false` still has to leave the element focusable in script. + * - ARIA boolean values are stringified: Solid 2.0 treats a boolean attribute + * as presence/absence, but ARIA states are literal "true"/"false" tokens. */ import type { AttrKey, @@ -16,137 +19,22 @@ import type { HandlerKey, HandlerTargets, } from '@dunky.dev/state-machine-bindings' +import { + DOM_ATTR_MAP, + DOM_HANDLER_MAP, + PAYLOAD_ADAPTERS, + type AnyEvent, +} from '@dunky.dev/state-machine-dom' export const HANDLER_MAP: HandlerTargets = { - 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). + ...DOM_HANDLER_MAP, onValueChange: 'onInput', - onContextMenu: 'onContextMenu', onDoublePress: 'onDblClick', - onWheel: 'onWheel', - onScroll: 'onScroll', - onScrollEnd: 'onScrollEnd', -} - -// These payload shapes differ from the native event, so normalize wraps the -// handler to hand the component the agnostic payload instead. (Pointer/keyboard -// events already overlap their payload shapes and pass through unwrapped.) - -// 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: boundPreventDefault(e) } - }, - onWheel: e => ({ - deltaX: e?.deltaX, - deltaY: e?.deltaY, - deltaZ: e?.deltaZ, - deltaUnit: WHEEL_UNIT[e?.deltaMode ?? 0] ?? 'pixel', - defaultPrevented: e?.defaultPrevented, - preventDefault: boundPreventDefault(e), - }), - onScroll: scrollPayload, - onScrollEnd: scrollPayload, -} - -// Keep `this = event`: a detached native preventDefault throws "illegal invocation". -function boundPreventDefault(e: AnyEvent): (() => void) | undefined { - return e?.preventDefault?.bind(e) -} - -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, - } } export const ATTR_MAP: AttrTargets = { - 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', + ...DOM_ATTR_MAP, focusable: 'tabindex', // value transformed below - role: 'role', - id: 'id', - - // labeling - label: 'aria-label', - // widget state ('mixed' tristate and the aria-current / aria-invalid enums - // pass through as-is; booleans are stringified in normalize below) - 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 diff --git a/packages/solid/tests/normalize.test.ts b/packages/solid/tests/normalize.test.ts index 7b24953..9022dcf 100644 --- a/packages/solid/tests/normalize.test.ts +++ b/packages/solid/tests/normalize.test.ts @@ -116,72 +116,13 @@ describe('solid 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('binds payload.preventDefault to the event (a detached native method throws)', () => { - const onValueChange = vi.fn() - const onWheel = vi.fn() - const out = normalize({ onValueChange, onWheel }) - // Fake event whose preventDefault asserts its `this`, like a native Event does. - const makeEvent = () => ({ - target: { value: 'x', type: 'text' }, - defaultPrevented: false, - preventDefault(this: { defaultPrevented: boolean }) { - this.defaultPrevented = true - }, - }) - const inputEvent = makeEvent() - ;(out.onInput as (e: unknown) => void)(inputEvent) - ;(onValueChange.mock.calls[0]![0] as { preventDefault: () => void }).preventDefault() - expect(inputEvent.defaultPrevented).toBe(true) - - const wheelEvent = makeEvent() - ;(out.onWheel as (e: unknown) => void)(wheelEvent) - ;(onWheel.mock.calls[0]![0] as { preventDefault: () => void }).preventDefault() - expect(wheelEvent.defaultPrevented).toBe(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' })) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2f50b..ea37742 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,12 @@ importers: packages/core: {} + packages/dom: + dependencies: + '@dunky.dev/state-machine-bindings': + specifier: workspace:* + version: link:../shared/bindings + packages/native: dependencies: '@dunky.dev/react-state-machine': @@ -159,6 +165,9 @@ importers: '@dunky.dev/state-machine': specifier: workspace:* version: link:../core + '@dunky.dev/state-machine-dom': + specifier: workspace:* + version: link:../dom '@dunky.dev/state-machine-utils': specifier: workspace:* version: link:../shared/utils @@ -194,6 +203,9 @@ importers: '@dunky.dev/state-machine': specifier: workspace:* version: link:../core + '@dunky.dev/state-machine-dom': + specifier: workspace:* + version: link:../dom '@dunky.dev/state-machine-utils': specifier: workspace:* version: link:../shared/utils diff --git a/sandbox/react/vite.config.ts b/sandbox/react/vite.config.ts index 1e11b4e..26d9620 100644 --- a/sandbox/react/vite.config.ts +++ b/sandbox/react/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ '@dunky.dev/react-state-machine': resolve(__dirname, '../../packages/react/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), + '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), }, }, diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index df6705d..eea5af4 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ '@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), + '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), }, }, diff --git a/tsdown.config.ts b/tsdown.config.ts index ed2218b..937d196 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ // choice. Keep in sync with the publish set in .changeset/config.json. workspace: [ 'packages/core', + 'packages/dom', 'packages/react', 'packages/solid', 'packages/native',