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/.changeset/payload-prevent-default-bound.md b/.changeset/payload-prevent-default-bound.md new file mode 100644 index 0000000..cb265f5 --- /dev/null +++ b/.changeset/payload-prevent-default-bound.md @@ -0,0 +1,22 @@ +--- +'@dunky.dev/react-state-machine': patch +'@dunky.dev/solid-state-machine': patch +--- + +`preventDefault` on the adapted payloads (`ChangePayload`, `WheelPayload`) now +actually works. `normalize()` used to copy the native event's `preventDefault` +onto the payload detached from its event, so the first `connect()` to call +`payload.preventDefault()` would throw `TypeError: Illegal invocation` — native +DOM methods require `this` to be a real `Event`. The payload now carries a +closure bound to the originating event: + +```ts +// connect() side — this used to throw, now suppresses the default as promised +onValueChange: payload => { + payload.preventDefault?.() +} +``` + +Latent until now (no in-repo `connect()` calls it yet), but it is the behavior +the bindings contract promises, so it's fixed in both DOM targets before a +component relies on it. diff --git a/.changeset/solid-effect-deps-untracked.md b/.changeset/solid-effect-deps-untracked.md new file mode 100644 index 0000000..df659c8 --- /dev/null +++ b/.changeset/solid-effect-deps-untracked.md @@ -0,0 +1,18 @@ +--- +'@dunky.dev/solid-state-machine': patch +--- + +A `ComponentEffect` body now runs untracked, so its authored `deps` list is the +whole re-run contract — identical to the React target's dep array. Previously +the body executed inside the tracking scope, so any prop the effect merely read +became a hidden dependency and re-ran it (cleanup + re-subscribe) on changes to +props it never declared. + +```ts +const escape: ComponentEffect = [ + (machine, props) => { + void props.onEscapeKeyDown // read, but NOT a dep — no longer re-runs on change + }, + ['closeOnEscape'], // ONLY this prop re-runs the effect, on every target +] +``` diff --git a/.changeset/solid-integration.md b/.changeset/solid-integration.md new file mode 100644 index 0000000..92f294e --- /dev/null +++ b/.changeset/solid-integration.md @@ -0,0 +1,25 @@ +--- +'@dunky.dev/solid-state-machine': minor +--- + +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 +field in JSX is fine-grained, runs the lifecycle through `onSettled`/`onCleanup`, +keeps props fresh with a tracked `setProps` effect, and runs each +`ComponentEffect` as its own dep-tracked `createEffect(compute, apply)`. +`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. + +Targets Solid 2.0 as a first-class citizen: the peer range is `solid-js` +`^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 +are version-split. Two consumer-facing 2.0 behaviors: writes commit on the +microtask queue (call `flush()` in tests before asserting), and JSX comes from +the renderer package (`"jsxImportSource": "@solidjs/web"`, `render` from +`@solidjs/web`). 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 f71fa52..b4bf4de 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/benchmark/tsconfig.json b/benchmark/tsconfig.json index 966d8b5..c85f0b0 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 1c4a591..1d8bb7a 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 .", 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/CHANGELOG.md b/packages/solid/CHANGELOG.md new file mode 100644 index 0000000..08a5d31 --- /dev/null +++ b/packages/solid/CHANGELOG.md @@ -0,0 +1 @@ +# @dunky.dev/solid-state-machine 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..300b6cc --- /dev/null +++ b/packages/solid/README.md @@ -0,0 +1,318 @@ +# `@dunky.dev/solid-state-machine` + +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 + once rendering settled (`onSettled`), stops it on disposal. +- 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 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(() => ({ ...props }), +snapshot => connection.setProps(snapshot))`. Solid auto-tracks every prop read + in the compute phase's spread, so it re-runs whenever a consumed prop changes, + with no manual dep list. `setProps` value-dedups. +- **runs the lifecycle** — `service.start()` in `onSettled`, `service.stop()` in + its returned cleanup. 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` — 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 +**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/solid-state-machine' + +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**. The compute phase +READS the declared prop deps, so Solid's auto-tracking re-runs the effect +(cleanup → setup) only when one of those props actually changes, never on +unrelated changes; the body runs in the apply phase, which Solid leaves +untracked. 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 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](../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). + +`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` — 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 +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 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` `^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 +code path cannot serve both majors, and 1.x lacks the root exports this package +imports. Like the rest of the Solid ecosystem (`@solidjs/router`, TanStack, +`solid-primitives`), the majors are version-split. + +Two 2.0 behaviors worth knowing as a consumer: + +- Writes commit on the microtask queue — after `send()`, a synchronous read of + the `api` store or a `useSelector` accessor returns the previous value until + the queue flushes. JSX readers always settle correctly; in tests, call + `flush()` from `solid-js` before asserting. +- JSX types live in the renderer package: set `"jsxImportSource": "@solidjs/web"` + and import `render` from `@solidjs/web`, not `solid-js/web`. diff --git a/packages/solid/package.json b/packages/solid/package.json new file mode 100644 index 0000000..7dd1d4f --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,53 @@ +{ + "name": "@dunky.dev/solid-state-machine", + "version": "0.2.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/solid" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:*", + "@dunky.dev/state-machine-dom": "workspace:*", + "@dunky.dev/state-machine-utils": "workspace:*" + }, + "devDependencies": { + "@dunky.dev/state-machine-bindings": "workspace:*", + "@solidjs/testing-library": "^1.0.0-beta.2", + "@solidjs/web": "^2.0.0-rc.1", + "jsdom": "^29.1.1", + "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.1" + } +} diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts new file mode 100644 index 0000000..eac700c --- /dev/null +++ b/packages/solid/src/index.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..eafdd2a --- /dev/null +++ b/packages/solid/src/merge-props.ts @@ -0,0 +1,31 @@ +import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' + +type AnyProps = Record + +/** + * Merge consumer props with the component's normalized props, Solid-style: + * the substrate-agnostic mergeProps (handlers compose, library wins) plus + * Solid's `class` concat and single-object `style` merge (library wins on + * conflicting keys; string styles fall through to library-wins). + */ +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 own.class === 'string' && typeof library.class === 'string') { + merged.class = `${own.class} ${library.class}`.trim() + } + if (isStyleObject(own.style) && isStyleObject(library.style)) { + merged.style = { ...own.style, ...library.style } + } + + return merged as Props & AnyProps +} + +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..05fba23 --- /dev/null +++ b/packages/solid/src/normalize.ts @@ -0,0 +1,73 @@ +/** + * 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, + 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 = { + ...DOM_HANDLER_MAP, + onValueChange: 'onInput', + onDoublePress: 'onDblClick', +} + +export const ATTR_MAP: AttrTargets = { + ...DOM_ATTR_MAP, + focusable: 'tabindex', // value transformed below +} + +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 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 + } + + const attr = ATTR_MAP[key as AttrKey] + if (attr) { + 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 + } + + 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..844f393 --- /dev/null +++ b/packages/solid/src/use-machine.ts @@ -0,0 +1,72 @@ +import { createEffect, createStore, onCleanup, onSettled, reconcile } from 'solid-js' +import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' + +/** + * One substrate-specific effect: a setup/teardown function plus the prop names + * that re-run it. The tuple shape is identical across every target, so a + * component's effects are authored once; `deps` are prop names (typed, so + * typos are compile errors) — the authored list is the whole re-run contract. + */ +export type ComponentEffect = [ + effect: (machine: Machine, props: Props) => (() => void) | void, + deps: (keyof Props)[], +] + +/** + * The generic Solid bridge: builds the machine + connector once, mirrors the + * connector's snapshot into a fine-grained store, runs the lifecycle and the + * component's effects. Returns the connect() api (reactive store proxy) and + * the running machine. + */ +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: ComponentEffect>, Props>[], + props: Props, +): { api: Api; machine: ReturnType> } { + // Seed with a plain copy, never the live props proxy: setProps value-dedups, + // and a held proxy would compare equal to its own fresh spread and never wake. + const service = machine(createConfig(props)) + const connection = connector(service, connect, { ...props }) + + // 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(() => + 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 + // this; setProps value-dedups. + createEffect( + () => ({ ...props }), + snapshot => connection.setProps(snapshot), + ) + + // No connection.destroy(): connector and machine share this component's + // lifetime and are GC'd together; destroy() is for standalone connectors. + onSettled(() => { + service.start() + return () => service.stop() + }) + + // One effect per entry: compute tracks exactly the named deps (fresh array, + // so apply fires on every dep change); the body runs untracked in apply, so + // a prop it merely reads never becomes a hidden dependency. + for (const [fn, deps] of effects) { + createEffect( + () => deps.map(key => props[key]), + () => fn(service, props), + ) + } + + 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..23a1a60 --- /dev/null +++ b/packages/solid/src/use-selector.ts @@ -0,0 +1,35 @@ +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 the machine directly; the returned accessor updates only when the + * selected VALUE changes (Object.is by default — pass `isEqual` for object + * selections), so unrelated machine changes never wake the reader. + * + * const open = useSelector(m, () => m.matches('open')) + */ +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 via the compute form on purpose: Solid 2.0's createSignal treats a + // function first argument as a compute, so a function-typed selection passed + // as a value would be misread. The compute has no reactive sources — it runs + // once; later changes arrive through the subscription. + const [value, setValue] = createSignal(() => selection.value, { equals: isEqual }) + + 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..baef81f --- /dev/null +++ b/packages/solid/tests/merge-props.test.ts @@ -0,0 +1,93 @@ +/** + * 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, expectTypeOf, it, vi } from 'vitest' +import { mergeProps } from '@dunky.dev/solid-state-machine' + +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') + }) + + 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', () => { + 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 new file mode 100644 index 0000000..9022dcf --- /dev/null +++ b/packages/solid/tests/normalize.test.ts @@ -0,0 +1,241 @@ +/** + * 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 '@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)', () => { + 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 hasPopup to aria-haspopup (string or boolean)', () => { + expect(normalize({ hasPopup: 'menu' })).toEqual({ 'aria-haspopup': 'menu' }) + expect(normalize({ hasPopup: true })).toEqual({ 'aria-haspopup': 'true' }) + }) + + // 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', + }) + }) + + 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) + }) + + // 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(expect.objectContaining({ value: 'hi' })) + }) +}) + +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' })) + }) +}) + +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 new file mode 100644 index 0000000..47f5481 --- /dev/null +++ b/packages/solid/tests/use-machine.test.tsx @@ -0,0 +1,383 @@ +// @vitest-environment jsdom +// `useMachine` behavioral contract: build once, machine lifecycle, props +// freshness, reactions, dep-tracked ComponentEffects, fine-grained api store. +import { createSignal, flush } 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 ComponentEffect, useMachine } from '@dunky.dev/solid-state-machine' + +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: ComponentEffect[] = [] + +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() + flush() // Solid 2.0 defers store commits + DOM updates to the microtask queue + 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', () => { + 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 + flush() + 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) + }) +}) + +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 + flush() + 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') + flush() + 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 + } + 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 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 }[] + parts: { getItemProps: (id: string) => Record } + toggle: () => void + } + const connectParts: Connect = ({ + state, + context, + send, + }) => ({ + open: state === 'open', + results: [{ id: 'a', label: `A${context.count}` }], + parts: { getItemProps: id => ({ id, open: state === 'open' }) }, + toggle: () => send({ type: 'toggle' }), + }) + + it('keeps readers of nested function leaves live across updates', () => { + let api: PartsApi | undefined + function Comp() { + const props: ToggleProps = {} + api = useMachine(createConfig(), connectParts, [], props).api + return ( +
+ {api.results.map(c => String(api!.parts.getItemProps(c.id)['open']))} +
+ ) + } + const { getByTestId } = render(() => ) + expect(getByTestId('row').textContent).toBe('false') + + api!.toggle() + flush() + expect(getByTestId('row').textContent).toBe('true') + + api!.toggle() + flush() + expect(getByTestId('row').textContent).toBe('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: ComponentEffect[] = [[() => (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: ComponentEffect[] = [[fn, ['label']]] + const [label, setLabel] = createSignal('a') + // Wrapped in an object: a bare function value would hit Solid 2.0's + // compute-form createSignal overload. + const [other, setOther] = createSignal<{ cb: (open: boolean) => void }>({ cb: () => {} }) + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + get onOpenChange() { + return other().cb + }, + } + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(fn).toHaveBeenCalledTimes(1) + + setOther({ cb: () => {} }) // non-dep prop changed → no re-run + flush() + expect(fn).toHaveBeenCalledTimes(1) + + setLabel('b') // dep changed → re-run + flush() + 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. + const fn = vi.fn((_m: ToggleMachine, props: ToggleProps) => { + void props.label // read a NON-dep prop inside the effect body + }) + const effects: ComponentEffect[] = [[fn, []]] + const [label, setLabel] = createSignal('a') + function Comp() { + const props: ToggleProps = { + get label() { + return label() + }, + } + useMachine(createConfig(), connect, effects, props) + return null + } + render(() => ) + expect(fn).toHaveBeenCalledTimes(1) + + setLabel('b') // read by the effect, but not in deps → no re-run + flush() + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('receives (machine, props) and can read live machine state', () => { + let seenOpen: boolean | undefined + const effects: ComponentEffect[] = [ + [ + 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..c9bb0cf --- /dev/null +++ b/packages/solid/tests/use-selector.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +// `useSelector` contract: value-deduped accessor (Object.is default, custom +// isEqual), and a slice change wakes only its own reader. +import { createEffect, flush } 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 '@dunky.dev/solid-state-machine' + +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' }) + flush() // Solid 2.0 defers signal commits to the microtask queue + expect(result()).toBe(1) + }) + + it('updates the accessor ONLY when the selected slice changes', () => { + const m = makeMachine() + const reads = vi.fn() + // A tracked reader of the accessor, created inside the hook render so it is + // owned; the apply phase re-runs only when the signal changes. + renderHook(() => { + const result = useSelector(m, () => m.context.a) + createEffect(() => result(), reads) + }) + flush() // effects run after the queue flushes + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selects `a`, `b` changed → no update + flush() + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'noop' }) // nothing changed → no update + flush() + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // `a` changed → update + flush() + 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() + renderHook(() => { + const result = useSelector(m, () => m.context.a > 0) + createEffect(() => result(), reads) + }) + flush() + expect(reads).toHaveBeenCalledTimes(1) + m.send({ type: 'incA' }) // false → true (update) + flush() + expect(reads).toHaveBeenCalledTimes(2) + m.send({ type: 'incA' }) // true → true (no update) + flush() + expect(reads).toHaveBeenCalledTimes(2) + }) +}) + +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() + const reads = vi.fn() + renderHook(() => { + const result = useSelector( + m, + () => ({ a: m.context.a }), + (x, y) => x.a === y.a, + ) + createEffect(() => result(), reads) + }) + flush() + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // selected {a} unchanged → no update + flush() + expect(reads).toHaveBeenCalledTimes(1) + + m.send({ type: 'incA' }) // {a} changed → update + flush() + expect(reads).toHaveBeenCalledTimes(2) + }) +}) + +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() + 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 + flush() + expect(aRenders).toHaveBeenCalledTimes(2) + expect(bRenders).toHaveBeenCalledTimes(1) + + m.send({ type: 'incB' }) // only LeafB's slice changed + flush() + 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..1716ed0 --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,12 @@ +{ + // Referenced from tsconfig/all.json; lives here (like vitest.config.ts) so + // the root workspace carries no Solid dependencies. Solid 2.0 moved the web + // JSX namespace out of `solid-js` into `@solidjs/web`. + "extends": "../../tsconfig/base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@solidjs/web" + }, + "include": ["src", "tests", "vitest.config.ts"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/packages/solid/vitest.config.ts b/packages/solid/vitest.config.ts new file mode 100644 index 0000000..813d2b5 --- /dev/null +++ b/packages/solid/vitest.config.ts @@ -0,0 +1,19 @@ +import solid from 'vite-plugin-solid' +import { defineConfig } from 'vitest/config' + +// Referenced as a project from the root vitest.config.ts; lives here so the +// root workspace carries no Solid dependencies. +export default defineConfig({ + plugins: [solid()], + resolve: { + // @solidjs/testing-library + the reactive runtime expect these conditions. + conditions: ['development', 'browser'], + }, + test: { + name: 'solid', + globals: false, + // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`. + environment: 'node', + include: ['tests/**/*.test.{ts,tsx}'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ef84d..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 @@ -189,6 +198,40 @@ importers: packages/shared/utils: {} + packages/solid: + dependencies: + '@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 + devDependencies: + '@dunky.dev/state-machine-bindings': + specifier: workspace:* + 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.1(solid-js@2.0.0-rc.1))(solid-js@2.0.0-rc.1) + '@solidjs/web': + 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.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.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)) + sandbox/native: dependencies: '@dunky.dev/native-state-machine': @@ -312,6 +355,37 @@ importers: specifier: workspace:^ version: link:../../packages/shared/bindings + 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-utils': + specifier: workspace:^ + version: link:../../packages/shared/utils + '@sandbox/cmdk-core': + specifier: workspace:^ + version: link:../shared + '@solidjs/web': + 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.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.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: '@astrojs/starlight': @@ -354,6 +428,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -464,6 +542,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'} @@ -1043,18 +1125,63 @@ packages: resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} + '@dom-expressions/babel-plugin-jsx@0.50.0-next.42': + resolution: {integrity: sha512-ol24x9RW8loPyOTzC/mQzh/zAsrsPxyTG4WRxxRlwzNK2uBWIBftWN5IwmSV51zDQa7JcT6sE6kkXFCLUvsYIQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + '@dom-expressions/compiler-darwin-arm64@0.50.0-next.40': + resolution: {integrity: sha512-+3aXdhw4SVt08fvgAsEM56ky/wdCVPXT0Wtxo164Cchgg7sapPsRqo8Gwwn0UU5AhVcEp3CIhdB5+pMoUicKFg==} + cpu: [arm64] + os: [darwin] + + '@dom-expressions/compiler-darwin-x64@0.50.0-next.40': + resolution: {integrity: sha512-Tbjg6ZQEIhKLKGd/Ep6MKqD76arPdV6SE2d3fkrP4qooIv2gYvAkvUw90qNdZxVHcuyjutrbJE0WpXYY9nSIIQ==} + cpu: [x64] + os: [darwin] + + '@dom-expressions/compiler-linux-arm64-gnu@0.50.0-next.40': + resolution: {integrity: sha512-tIJMY8dPyjiYNSL5uc4JA5l88Sw0WoMwoAilqTTHpGYdCL5GwzGq6ZOV1bndw8XKEYFYfnTr276FDHyYpLtRYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@dom-expressions/compiler-linux-x64-gnu@0.50.0-next.40': + resolution: {integrity: sha512-H/K/3Ykk8aCSNuKDlv/sgbPdSks+zqFxZ4mzqaGJmlDqEfeaWYXan5fsvwQbACdNDJeKoKLO4GhnlnKlKWjiJg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@dom-expressions/compiler-wasm32-wasi@0.50.0-next.40': + resolution: {integrity: sha512-2SAfc35FEvvxkUz2yeh/4aNx0s9waZnce4KS64oUMlVpYEFtNBdfnZKCB/bgzJFvTxXjMs+HcU6OInGltH5jHA==} + engines: {node: '>=14.0.0'} + + '@dom-expressions/compiler-win32-x64-msvc@0.50.0-next.40': + resolution: {integrity: sha512-bBdHMxdfUHtIrS5xrt9udqdGRnGKdYQgKxJNIts+Il3Fs8QGyNwaZpi0tjnlRjYa0+GdWEC3Gw1Pmq/HFXzFeQ==} + cpu: [x64] + os: [win32] + + '@dom-expressions/compiler@0.50.0-next.40': + resolution: {integrity: sha512-RI/kHU+QkLOHo4CQyqLTn9c7sAfl/GEULMsOpS1YqkPJgy7R8MCPWXFN4sI0QDBbM6ePtEyuW+2bsdsXqyxkzQ==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -1752,6 +1879,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2864,6 +2998,32 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@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==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/web': '>=2.0.0' + solid-js: '>=2.0.0' + + '@solidjs/vite-plugin@3.0.0-next.28': + resolution: {integrity: sha512-P/Xova2R8QoveQ2szrzkHSMPZjIyc5dE4hh2UHNgRW8CC5sSoh8yzPLw7qwvKdE1DydPHUWq/hrRIApkwt+grw==} + peerDependencies: + '@solidjs/web': ^2.0.0-rc.0 + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^2.0.0-rc.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + '@solidjs/web@2.0.0-rc.1': + resolution: {integrity: sha512-wLuxGtQUxaFfqxqhIUJGGSZB/upd3GzokQRFJKvO7biJGNZLAws+eanMqi0kK2Amg+MZZ7aVyPPKydf8mzdhkg==} + peerDependencies: + solid-js: ^2.0.0-rc.1 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2981,6 +3141,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3444,6 +3607,15 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-preset-solid@2.0.0-rc.0: + resolution: {integrity: sha512-Ap2/QQY3pICj+Q0VM/RnIOpZo7e6icZnUA0oBJuhqzoCrljqMNo3eFb2OeEa4pUQeFREJOlex4Bt1ggwrcgC8w==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^2.0.0-rc.0 + peerDependenciesMeta: + solid-js: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4384,6 +4556,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==} @@ -4531,6 +4706,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'} @@ -4875,6 +5054,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==} @@ -5930,6 +6113,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'} @@ -5998,6 +6191,9 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + 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==} engines: {node: '>=0.10.0'} @@ -6496,6 +6692,9 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + validate-html-nesting@1.2.4: + resolution: {integrity: sha512-doQi7e8EJ2OWneSG1aZpJluS6A49aZM0+EICXWKm1i6WvqTLmq0tpUcImc4KTWG50mORO0C4YDBtOCSYvElftw==} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -6513,6 +6712,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-solid@3.0.0-next.27: + resolution: {integrity: sha512-bDzjIIplkSDH73BiGP9pbPR3ZnjeUA18SAYugLhqDCy4u0bl3qdrETstxrXuo2vHXLB0VD9rvOr0iesZBBul4Q==} + vite@7.3.5: resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6850,6 +7052,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -7079,6 +7286,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 @@ -7809,6 +8020,47 @@ snapshots: '@ctrl/tinycolor@4.2.0': {} + '@dom-expressions/babel-plugin-jsx@0.50.0-next.42(@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 + validate-html-nesting: 1.2.4 + + '@dom-expressions/compiler-darwin-arm64@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-darwin-x64@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-linux-arm64-gnu@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-linux-x64-gnu@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler-wasm32-wasi@0.50.0-next.40': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@dom-expressions/compiler-win32-x64-msvc@0.50.0-next.40': + optional: true + + '@dom-expressions/compiler@0.50.0-next.40': + optionalDependencies: + '@dom-expressions/compiler-darwin-arm64': 0.50.0-next.40 + '@dom-expressions/compiler-darwin-x64': 0.50.0-next.40 + '@dom-expressions/compiler-linux-arm64-gnu': 0.50.0-next.40 + '@dom-expressions/compiler-linux-x64-gnu': 0.50.0-next.40 + '@dom-expressions/compiler-wasm32-wasi': 0.50.0-next.40 + '@dom-expressions/compiler-win32-x64-msvc': 0.50.0-next.40 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -7821,6 +8073,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -7831,6 +8089,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -8580,14 +8843,21 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.3 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 '@emnapi/runtime': 1.11.0 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 optional: true '@nodelib/fs.scandir@2.1.5': @@ -9387,6 +9657,35 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solidjs/signals@2.0.0-rc.1': {} + + '@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.1(solid-js@2.0.0-rc.1) + '@testing-library/dom': 10.4.1 + solid-js: 2.0.0-rc.1 + + '@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.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.1) + merge-anything: 5.1.7 + 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.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.1 + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.3.1': @@ -9484,6 +9783,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -9654,6 +9958,14 @@ snapshots: 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/mocker@4.1.7(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: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + 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/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 @@ -10051,6 +10363,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@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.1 + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -11129,6 +11448,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -11240,6 +11561,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -11738,6 +12061,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: {} @@ -13520,6 +13847,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 @@ -13619,6 +13952,13 @@ snapshots: smol-toml@1.6.1: {} + solid-js@2.0.0-rc.1: + dependencies: + '@solidjs/signals': 2.0.0-rc.1 + csstype: 3.2.3 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -14026,6 +14366,8 @@ snapshots: uuid@7.0.3: {} + validate-html-nesting@1.2.4: {} + validate-npm-package-name@5.0.1: {} vary@1.1.2: {} @@ -14045,6 +14387,16 @@ 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.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.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' + - solid-js + - supports-color + - vite + 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 @@ -14098,6 +14450,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@24.13.2)(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@24.13.2)(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 @@ -14126,6 +14482,34 @@ snapshots: transitivePeerDependencies: - msw + vitest@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)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(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/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.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) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vlq@1.0.1: {} w3c-xmlserializer@5.0.0: 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/README.md b/sandbox/README.md index 3adf23c..4f45dc5 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,31 @@ 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) +| + 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 ++-- 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. ## 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 +41,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/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/index.html b/sandbox/solid/index.html new file mode 100644 index 0000000..1f700fa --- /dev/null +++ b/sandbox/solid/index.html @@ -0,0 +1,12 @@ + + + + + + cmdk · Solid + + +
+ + + diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json new file mode 100644 index 0000000..e9ac5b6 --- /dev/null +++ b/sandbox/solid/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sandbox/cmdk-solid", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "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-utils": "workspace:^", + "@sandbox/cmdk-core": "workspace:^", + "@solidjs/web": "^2.0.0-rc.1", + "solid-js": "^2.0.0-rc.1" + }, + "devDependencies": { + "vite": "^8.0.14", + "vite-plugin-solid": "^3.0.0-next.27" + } +} diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx new file mode 100644 index 0000000..3d9110c --- /dev/null +++ b/sandbox/solid/src/app.tsx @@ -0,0 +1,30 @@ +import { createSignal } from 'solid-js' +import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { CommandPalette } from './command-palette' + +export function App() { + const [last, setLast] = createSignal('—') + + return ( +
+

⌘K Command Palette

+
+ { + setLast(c.label) + window.alert(`Selected: ${c.label}`) + }} + /> +
+

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

+

+ Last selected: {last()} +

+
+ ) +} diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx new file mode 100644 index 0000000..74a5b1b --- /dev/null +++ b/sandbox/solid/src/command-palette.tsx @@ -0,0 +1,91 @@ +import { createEffect, For, Show } from 'solid-js' +import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' +import { + commandPaletteMachineConfig, + type CommandPaletteMachine, + type CommandPaletteProps, + connectCommandPalette, +} from '@sandbox/cmdk-core' + +// Global ⌘K / Ctrl+K to open — a platform listener, so it lives here as a +// component effect, not in the machine. Same tuple shape as the React sandbox. +const cmdkShortcut: ComponentEffect = [ + machine => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + machine.send({ type: 'open' }) + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, + [], +] + +// The DOM renderer — zero interaction logic; `useMachine` runs the shared +// machine and `normalize` maps the logical bindings to DOM props. The +// component is just markup; the look lives in the stylesheet shared with the +// React app. +export function CommandPalette(props: CommandPaletteProps) { + const { api } = useMachine( + commandPaletteMachineConfig, + connectCommandPalette, + [cmdkShortcut], + props, + ) + + let inputEl: HTMLInputElement | undefined + + // Focus on open; drop the ref on close so a detached isn't retained. + // (The drop lives here because Solid 2.0 refs are unowned — no onCleanup + // inside ref callbacks.) + createEffect( + () => api.open, + open => { + if (open) inputEl?.focus() + else inputEl = undefined + }, + ) + + return ( +
+ + + +
api.setOpen(false)}> +
e.stopPropagation()}> + (inputEl = el)} + {...normalize(api.parts.input)} + value={api.query} + placeholder='Type a command…' + class='cmdk-input' + /> +
    + +
  • No results
  • +
    + + {(command, index) => { + const itemProps = () => normalize(api.parts.getItemProps(command, index())) + const selected = () => command.id === api.activeId + return ( +
  • + {command.label} + + {command.hint} + +
  • + ) + }} +
    +
+
+
+
+
+ ) +} diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx new file mode 100644 index 0000000..ed52a73 --- /dev/null +++ b/sandbox/solid/src/main.tsx @@ -0,0 +1,10 @@ +import { render } from '@solidjs/web' +import { App } from './app' + +// The stylesheet both web sandboxes share. +import '../../shared/src/styles.css' + +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..bfaafde --- /dev/null +++ b/sandbox/solid/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@solidjs/web", + "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..eea5af4 --- /dev/null +++ b/sandbox/solid/vite.config.ts @@ -0,0 +1,23 @@ +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: { + // One Solid runtime for app + aliased packages — two copies means silently + // dead reactivity. + dedupe: ['solid-js', '@solidjs/web'], + alias: { + '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/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'), + '@dunky.dev/state-machine-dom': resolve(__dirname, '../../packages/dom/src'), + '@sandbox/cmdk-core': resolve(__dirname, '../shared/src'), + }, + }, +}) 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 89b203e..967cf8e 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/react-state-machine": ["./packages/react/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"], - "@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..9ee2bb9 --- /dev/null +++ b/tsconfig/all.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./react.json" }, + { "path": "../packages/solid" }, + { "path": "../benchmark/tsconfig.json" } + ] +} diff --git a/tsconfig/base.json b/tsconfig/base.json new file mode 100644 index 0000000..49f680f --- /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/react-state-machine": ["../packages/react/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"], + "@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/tsdown.config.ts b/tsdown.config.ts index 99165b5..937d196 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,9 @@ 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', 'packages/opentui', 'packages/shared/utils', diff --git a/vitest.config.ts b/vitest.config.ts index c3bca12..117b2dc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,21 @@ import { defineConfig } from 'vitest/config' +// Two projects: the Solid tests need vite-plugin-solid's JSX transform, which +// must not rewrite the React `.tsx` tests. The solid project lives with its +// package so the root carries no Solid dependencies. 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/**'], + }, + }, + './packages/solid/vitest.config.ts', + ], }, }) diff --git a/website/astro.config.ts b/website/astro.config.ts index 61aa751..20f7ef6 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -128,6 +128,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/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 new file mode 100644 index 0000000..13de5af --- /dev/null +++ b/website/src/content/docs/libs/solid.mdx @@ -0,0 +1,180 @@ +--- +title: Solid +description: Solid bindings for @dunky.dev/state-machine. +--- + +import Install from '../../../components/install.astro' + + + +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. + +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 { Show } from 'solid-js' +import { useMachine, normalize } from '@dunky.dev/solid-state-machine' +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 ( + <> + + +
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 { setup, 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 +} + +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' } } }, + }, + }) + +export const connectDialog: Connect = ({ + state, + send, +}) => ({ + isOpen: state === 'open', + triggerProps: { + onPress: () => send({ type: 'open' }), + expanded: state === 'open', + }, + contentProps: { role: 'dialog', modal: true }, +}) + +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, ... } +``` + +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). + +## `mergeProps`: consumer + component props + +When a consumer spreads their own props onto the same element the component controls: + +```tsx +import { mergeProps, normalize } from '@dunky.dev/solid-state-machine' + +