From 7b4b75ef5b921a0e554b1ccf2819c6f632e3f96a Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 14 Jul 2026 18:09:49 +0200 Subject: [PATCH 1/3] refactor!: merge ComponentEffects into ComponentEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComponentEffects was a pure alias for ComponentEffect[] — a second public name for the same concept with no semantics of its own (it can't enforce the static-list rule it documented). One concept, one export: the effects param is now typed ComponentEffect[], and the static-list rule lives in ComponentEffect's docblock. Removed from the react package and its native re-export. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/drop-component-effects-alias.md | 19 +++++++++++++++++++ packages/native/src/index.ts | 7 +------ packages/react/src/index.ts | 2 +- packages/react/src/use-machine.ts | 11 ++++------- packages/react/tests/use-machine.test.tsx | 14 +++++++------- 5 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 .changeset/drop-component-effects-alias.md diff --git a/.changeset/drop-component-effects-alias.md b/.changeset/drop-component-effects-alias.md new file mode 100644 index 0000000..c7ff9de --- /dev/null +++ b/.changeset/drop-component-effects-alias.md @@ -0,0 +1,19 @@ +--- +'@dunky.dev/react-state-machine': minor +'@dunky.dev/native-state-machine': minor +--- + +**Breaking:** remove the `ComponentEffects` type alias. It was a pure +alias for `ComponentEffect[]` — a second public name for the same +concept, with no semantics of its own (it can't enforce the static-list rule +it documented). One concept, one export. + +Migration is mechanical: + +```diff +-import { type ComponentEffects } from '@dunky.dev/react-state-machine' ++import { type ComponentEffect } from '@dunky.dev/react-state-machine' + +-const tooltipEffects: ComponentEffects = [trackEscape] ++const tooltipEffects: ComponentEffect[] = [trackEscape] +``` diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 133a1bc..d19bf61 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -3,12 +3,7 @@ // they're imported straight from machine-react rather than duplicated here. Only // the substrate-specific translation (normalize → RN props, RN-aware mergeProps) // lives in this package. -export { - useMachine, - useSelector, - type ComponentEffect, - type ComponentEffects, -} from '@dunky.dev/react-state-machine' +export { useMachine, useSelector, type ComponentEffect } from '@dunky.dev/react-state-machine' export { normalize, type Bindings } from './normalize' // RN-aware mergeProps (handler compose + style array) layered on the diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d113d89..eac700c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,4 @@ -export { useMachine, type ComponentEffect, type ComponentEffects } from './use-machine' +export { useMachine, type ComponentEffect } from './use-machine' export { useSelector } from './use-selector' export { normalize, type Bindings } from './normalize' export { mergeProps } from './merge-props' diff --git a/packages/react/src/use-machine.ts b/packages/react/src/use-machine.ts index 85530f9..541d6f8 100644 --- a/packages/react/src/use-machine.ts +++ b/packages/react/src/use-machine.ts @@ -9,18 +9,15 @@ import { connector, machine, type Connect, type TransitionConfig } from '@dunky. * (machine, props) => { addEventListener(…); return () => removeEventListener(…) }, * ['closeOnEscape'], * ] + * + * A component passes `useMachine` a plain `ComponentEffect[]` list, which MUST be a stable + * module constant — one `useEffect` runs per entry, so the length must not change between renders. */ export type ComponentEffect = [ effect: (machine: Machine, props: Props) => (() => void) | void, deps: (keyof Props)[], ] -/** - * A component's substrate effects. MUST be a stable module constant — `useMachine` calls - * one `useEffect` per entry, so the list length must not change between renders. - */ -export type ComponentEffects = ComponentEffect[] - /** * The generic React bridge. Builds the machine once from the first render's props, * keeps props fresh via setProps, runs substrate effects, and drives React via @@ -36,7 +33,7 @@ export function useMachine< >( createConfig: (props: Props) => TransitionConfig, connect: Connect, - effects: ComponentEffects>, Props>, + effects: ComponentEffect>, Props>[], props: Props, ): { api: Api; machine: ReturnType> } { const { service, connection } = useMemo( diff --git a/packages/react/tests/use-machine.test.tsx b/packages/react/tests/use-machine.test.tsx index d17f476..996aa71 100644 --- a/packages/react/tests/use-machine.test.tsx +++ b/packages/react/tests/use-machine.test.tsx @@ -17,7 +17,7 @@ import { type Connect, type TransitionConfig, } from '@dunky.dev/state-machine' -import { type ComponentEffects, useMachine } from '@dunky.dev/react-state-machine' +import { type ComponentEffect, useMachine } from '@dunky.dev/react-state-machine' type ToggleState = 'closed' | 'open' interface ToggleCtx { @@ -70,13 +70,13 @@ connect.reactions = [ ] type ToggleMachine = ReturnType> -const noEffects: ComponentEffects = [] +const noEffects: ComponentEffect[] = [] afterEach(() => vi.clearAllMocks()) function harness( props: ToggleProps, - effects: ComponentEffects = noEffects, + effects: ComponentEffect[] = noEffects, ) { const sink: { api?: ToggleApi; machine?: ToggleMachine; renders: number } = { renders: 0 } function Comp(p: ToggleProps) { @@ -166,7 +166,7 @@ describe('useMachine — component effects', () => { it('runs each ComponentEffect as its own effect (setup on mount, cleanup on unmount)', () => { const setup = vi.fn() const cleanup = vi.fn() - const effects: ComponentEffects = [[() => (setup(), cleanup), []]] + const effects: ComponentEffect[] = [[() => (setup(), cleanup), []]] const { Comp } = harness({}, effects) const { unmount } = render() expect(setup).toHaveBeenCalledOnce() @@ -177,7 +177,7 @@ describe('useMachine — component effects', () => { it('re-runs an effect ONLY when one of its named prop deps changes', () => { const fn = vi.fn(() => () => {}) - const effects: ComponentEffects = [[fn, ['label']]] + const effects: ComponentEffect[] = [[fn, ['label']]] const { Comp } = harness({ label: 'a' }, effects) const { rerender } = render() expect(fn).toHaveBeenCalledTimes(1) @@ -191,7 +191,7 @@ describe('useMachine — component effects', () => { it('does NOT re-run an effect when a NON-dep prop changes', () => { const fn = vi.fn(() => () => {}) - const effects: ComponentEffects = [[fn, ['label']]] + const effects: ComponentEffect[] = [[fn, ['label']]] const { Comp } = harness({ label: 'a' }, effects) const { rerender } = render( {}} />) expect(fn).toHaveBeenCalledTimes(1) @@ -201,7 +201,7 @@ describe('useMachine — component effects', () => { it('receives (machine, props) and can read live machine state', () => { let seenOpen: boolean | undefined - const effects: ComponentEffects = [ + const effects: ComponentEffect[] = [ [ m => { seenOpen = m.matches('open') From 6f463adedca36fe0a0734a29f4954a3247b6db8e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 14 Jul 2026 18:10:04 +0200 Subject: [PATCH 2/3] docs(react): rewrite the README around a tooltip quick start The old README opened mid-architecture (the generated useXxxApi framing) and had no complete example. Now: a short what-this-package-does list, a vertical flow diagram, an end-to-end tooltip quick start (config -> connect -> component), and trimmed reference sections. The normalize mapping table is replaced with a pointer to src/normalize.ts so it can't go stale. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/README.md | 355 ++++++++++++++++++++------------------- 1 file changed, 179 insertions(+), 176 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index b634d08..e91c751 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,90 +1,167 @@ # `@dunky.dev/react-state-machine` The **React bindings** for [`@dunky.dev/state-machine`](../core/README.md). -The core engine is renderer-agnostic; this package is the thin React edge that -drives it: it builds the machine + connector, runs the React lifecycle, bridges -the connector's snapshot into React rendering, translates the agnostic -[bindings](../core/README.md#connector--the-view-boundary) vocabulary into DOM -props, and owns the per-component substrate effects. - -Everything here is deliberately small — the behavior lives in the core machine -and the component's `connect`; this layer only adapts them to React. There are -four exports: one bridge hook (`useMachine`, which also runs the component's -substrate effects), one leaf-subscription hook (`useSelector`), and two prop -helpers (`normalize`, `mergeProps`) — plus the `ComponentEffect` types. + +The behavior lives in the core machine — plain TypeScript, no renderer. This +package is the thin React edge that runs it. It does four things: + +1. **`useMachine`** — build the machine once, run its lifecycle, re-render when + it changes, run the component's platform effects. +2. **`useSelector`** — re-render 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 (React) + | + | useMachine build + start the machine, subscribe + | | + | v + | api the view surface instructions + | | + | v + | normalize() DOM / ARIA / events + | + v + + {api.open &&
I'm a tooltip
} + + ) +} +``` + +What happened: + +- `useMachine` built the machine and connector **once** (the first render's + props seeded the initial state), started it on mount, stops it on unmount. +- Hovering sends plain events; the machine handles the 300ms open delay + itself (`after`) — no `setTimeout` in the component. +- The component re-renders only when the `connect()` output actually changes. +- `normalize` turned `describedBy` into `aria-describedby` — the same + `connect` could drive React Native or a canvas through _their_ `normalize`. + +That's the whole model. Everything below is reference. --- -## `useMachine` — the one bridge hook +## `useMachine` — the bridge hook -Every component's generated `useXxxApi` calls this with the agnostic pieces: +One call per component instance. (In the full Dunky pipeline a component's +generated `useXxxApi` makes this call; hand-written components call it +directly, the same way.) ```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[]) - resolved, // props with defaults applied -) +const { api, machine } = useMachine(createConfig, connect, effects, props) ``` -It: - -- **builds once** (in `useMemo` with an empty dep array) — - `machine(createConfig(props))` + `connector(service, connect, props)`. The - first render's props seed context and the initial state; recreating would lose - state, so later prop changes flow through `setProps`, not a rebuild. -- **keeps props fresh** via a passive effect (`connection.setProps(props)`) — - never during render (writing the props signal mid-render would notify - `useSyncExternalStore` and loop with _"cannot update a component while - rendering"_). The connector was seeded with the first render's props in - `useMemo`, so the initial snapshot is already correct; this only pushes - _subsequent_ changes. `setProps` value-dedups, so a consumer that rebuilds an - equal props object each render doesn't churn. -- **runs the lifecycle**: `service.start()` on mount, `service.stop()` on unmount. - The connector wired its +| Argument | What it is | +| -------------- | -------------------------------------------------------------------------- | +| `createConfig` | `(props) => config` — called **once**, with the first render's props | +| `connect` | pure `connect()`: snapshot → the api the view spreads | +| `effects` | the component's `ComponentEffect[]` — static module constant; `[]` if none | +| `props` | current props, defaults already applied | + +Returns `{ api, machine }` — `api` to render from; `machine` to `send` to and +to hand to `useSelector`. + +What it guarantees: + +- **Builds once.** Machine + connector are created on the first render and + never rebuilt (rebuilding would lose state). Later prop changes flow through + `setProps` — value-deduped, and applied in a passive effect, never during + render (a mid-render store write would notify `useSyncExternalStore` and + loop). Consequence: **props seed the machine once** — initial state and + context come from the first render; after that, changed props reach the + component through the connector, not a rebuild. +- **Lifecycle.** `start()` on mount, `stop()` on unmount — StrictMode's + mount → unmount → mount included. The connector's [reactions](../core/README.md#reactions--firing-prop-callbacks-without-the-machine-knowing) - to the machine's own `start`/`stop`, so prop-callbacks follow automatically - (StrictMode mount→unmount→mount included), with no teardown threading here. -- **runs the component's substrate effects** — one `useEffect` per - `ComponentEffect` entry, each keyed on its named prop deps (see below). The - generated `useApi` no longer touches React directly; passing the effects list - here is all it does. -- **drives React** via - `useSyncExternalStore(connection.subscribe, () => connection.snapshot)` over the - connector's stable, memoized snapshot — its identity only changes on a real - change, so there's no infinite-loop / tearing. - -Returns `{ api, machine }`: `api` is the `connect()` output to spread onto -elements; `machine` is the running service (also handed to `useSelector`). + (prop callbacks) follow the machine's lifecycle automatically. +- **Platform effects.** One `useEffect` per `ComponentEffect` entry, each + keyed on its own named prop deps (see next section). +- **Rendering.** `useSyncExternalStore` over the connector's memoized + snapshot — its identity changes only on a real change, so no tearing and no + render loops. --- -## `ComponentEffect` — substrate transport, without the boilerplate +## `ComponentEffect` — platform effects, next to the component -Some behavior can't live in the agnostic machine because it needs the **platform -itself** — a DOM `keydown` listener for Escape, a `ResizeObserver` — and the -**props** the machine never sees (`closeOnEscape`, a prevent-able -`onEscapeKeyDown` veto). That's the component's React-side _effect_. - -Each effect is a `[setup/teardown, depPropNames]` tuple (`ComponentEffect`). A -component declares one named const per effect — aliasing the type once keeps the -annotations short — and exports a flat list. **No React in the component file** — -the generated `useApi` owns the `useEffect`s: +Some behavior can't live in the agnostic machine because it needs the +**platform** (a DOM `keydown`, a `ResizeObserver`) or the **props** the machine +[never sees](../core/README.md#the-machine-never-sees-props). That behavior is +a `ComponentEffect`: a `[setup/teardown, depPropNames]` tuple, declared as a +named const — **no React in the component file**; `useMachine` owns the +`useEffect`s. ```ts -// a target component's effects.ts (illustrative — components live outside this repo) import type { ComponentEffect } from '@dunky.dev/react-state-machine' type TooltipEffect = ComponentEffect -/** Escape-to-close (gated by closeOnEscape; honors the onEscapeKeyDown veto). */ +/** Escape-to-close — the DOM transport for a decision the core resolver makes. */ const trackEscape: TooltipEffect = [ (machine, props) => { if (!props.closeOnEscape) return const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Escape') return - // defer the decision to the agnostic resolver; act on its verdict if (resolveEscape({ ...props, state: machine.state }).close) { e.stopPropagation() machine.send({ type: 'escape' }) @@ -93,73 +170,41 @@ const trackEscape: TooltipEffect = [ document.addEventListener('keydown', onKeyDown, true) return () => document.removeEventListener('keydown', onKeyDown, true) }, - ['closeOnEscape', 'onEscapeKeyDown'], // ← re-run only when these props change + ['closeOnEscape', 'onEscapeKeyDown'], // re-run only when THESE props change ] -// a component with more effects adds more named consts, each with its OWN deps: -// const tabTrap: TooltipEffect = [tabFn, ['focusTrap']] export const tooltipEffects = [trackEscape] ``` -Named consts over inline tuples: each effect gets a label (`trackEscape`), the -export is a flat readable list (no `[[…],[…]]` nesting), and each effect's deps -sit next to it. The export type is inferred (`ComponentEffect[]`). - -`useMachine` runs the list — **one `useEffect` per entry**, each with a **precise -dependency array** built from that entry's named props (so the component file -never touches React): - -```ts -// inside useMachine, after start(): -// for each [fn, deps] of effects: -// useEffect(() => fn(machine, resolved), [machine, ...deps.map(k => resolved[k])]) -``` - -**Why a list of per-effect deps** (not one combined set): each effect -re-subscribes only when _its own_ deps change — toggling `focusTrap` doesn't -churn the Escape listener. - -**Why named deps** (not the whole props object): `resolved` is a fresh object -every render, so `[machine, resolved]` would re-run **every render**. Naming the -props — typed `(keyof Props)[]`, so a typo is a compile error — re-runs an effect -_only when one of its values actually changes_, never stale. `machine` is always -an implicit dep. - -> The list **must be a static module constant** — `useMachine` calls one hook per -> entry, so its length can't vary between renders (rules-of-hooks). Declaring it -> as `export const xEffects = [...]` guarantees that; never build it conditionally -> or per-render. +The rules, and why: -> The agnostic _decision_ (gate + veto) lives in the core component's resolver -> (`resolveEscape`); only the _transport_ (the DOM listener) is here. Same split -> as everywhere: agnostic policy in core, platform wiring at the edge. The machine -> just receives a plain `escape` event. This is the React counterpart of a core -> `effect` — but one that may read props and touch the DOM, which a core effect -> can't. +- **Declare the list once, at module level.** Each entry becomes a `useEffect` + call, and React forbids a changing number of hooks — so never add/remove + entries conditionally or build the array inside the component. +- **Deps are prop names.** They become the `useEffect` dep array, so the + effect re-runs only when one of those props changes. +- **Each effect has its own dep list, not one shared set.** A prop change + re-runs only the effects that declared that prop. --- -## `useSelector` — fine-grained leaf subscription +## `useSelector` — fine-grained subscription -For a leaf component that should re-render only when **one slice** of the machine -changes (not on every machine change) — the `O(readers)` path that matters at -scale (e.g. thousands of menu items, each re-rendering only when _its own_ -highlighted state flips): +For a leaf that should re-render only when **one slice** of the machine +changes — the `O(readers)` path that matters at scale (thousands of menu +items, each re-rendering only when _its own_ highlight flips): ```ts const open = useSelector(machine, () => machine.matches('open')) const isHL = useSelector(machine, () => machine.context.highlightedValue === value) ``` -The selector reads from the machine directly, so it auto-subscribes to exactly -the fields it touches (the same auto-tracking the core's -[`select`](../core/README.md#subscriptions--observing-changes) gives you); the -component re-renders only when the selected value changes — `Object.is` by -default. **A selector that returns a fresh object/array each call MUST pass a -custom `isEqual`** — otherwise every evaluation yields a new identity that -`Object.is` deems "changed", and `useSyncExternalStore` re-renders in a loop. -Prefer selecting primitives; reach for `isEqual` when you genuinely need a -composite: +Re-renders only when the selected value changes (`Object.is` by default). + +> **A selector returning a fresh object/array each call MUST pass a custom +> `isEqual`** — otherwise every read is a "new" value and the component +> re-renders in a loop. Prefer selecting primitives; reach for `isEqual` when +> you genuinely need a composite: ```ts const pos = useSelector( @@ -169,86 +214,45 @@ const pos = useSelector( ) ``` -Internally it wraps the selector in one memoized `Selection` and feeds it through -`useSyncExternalStore`, caching the value in a ref so `getSnapshot` stays -referentially stable between real changes. - -**`useMachine` vs. `useSelector`.** `useMachine` is the per-instance bridge: it -drives the whole component off the connector's coarse snapshot (the connector -already memoizes, so a render only happens on a real change). `useSelector` is for -_within_ that tree — a child that wants to re-render on just one field, decoupled -from the parent's snapshot. Reach for it when a component subtree is large enough -that whole-snapshot re-renders are wasteful. +**vs `useMachine`:** `useMachine` re-renders the component on any machine +change; `useSelector` re-renders a child on one field. --- ## `normalize` — agnostic bindings → DOM props -`connect` returns substrate-agnostic [bindings](../core/README.md#connector--the-view-boundary) -(`onPress`, `describedBy`, `role`). `normalize` translates them to real DOM/ARIA -props so the same `connect` can target DOM, React Native, or canvas — each via its -own `normalize`: +`connect` returns substrate-agnostic +[bindings](../core/README.md#connector--the-view-boundary); `normalize` +translates them into real DOM/ARIA props: ```ts const domProps = normalize(api.triggerProps) // { onClick, aria-describedby, role, ... } ``` -The mapping: - -| Agnostic binding | DOM/ARIA prop | -| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `onPress` | `onClick` | -| `onValueChange` | `onChange` (wrapped → `ChangePayload`) | -| `onContextMenu` / `onDoublePress` | `onContextMenu` / `onDoubleClick` | -| `onWheel` / `onScroll` / `onScrollEnd` | same name (wrapped → `WheelPayload` / `ScrollPayload`) | -| `onPointerEnter/Leave/Move/Down/Up/Cancel`, `onFocus/Blur`, `onKeyDown/Up` | same name (already DOM-shaped) | -| `describedBy` / `labelledBy` / `controls` / `label` | `aria-describedby` / `aria-labelledby` / `aria-controls` / `aria-label` | -| `expanded` / `selected` / `disabled` / `hidden` / `modal` | `aria-expanded` / `aria-selected` / `aria-disabled` / `aria-hidden` / `aria-modal` | -| `checked` / `pressed` / `current` / `busy` / `invalid` / `required` / `readOnly` | matching `aria-*` (value untransformed) | -| `valueMin/Max/Now/Text` | `aria-valuemin` / `-valuemax` / `-valuenow` / `-valuetext` | -| `orientation` / `sort` / `autoComplete` / `level` / `posInSet` / `setSize` / grid `col*`/`row*` | the matching `aria-*` attr | -| `activeDescendant` / `errorMessage` / `owns` / `hasPopup` | `aria-activedescendant` / `-errormessage` / `-owns` / `-haspopup` | -| `live` / `atomic` | `aria-live` / `aria-atomic` | -| `focusable` | `tabIndex` (`true → 0`, `false → -1`) | -| `role` / `id` | `role` / `id` | - -The logical names are renderer-neutral by design — `onPress` not `onClick`, -`onValueChange` not `onChange`, `checked` not `aria-checked` — so the same -`connect` output drives DOM, React Native, or canvas, each through its own -`normalize`. A few handlers whose agnostic payload differs from the raw event -(`onValueChange`/`onWheel`/`onScroll`/`onScrollEnd`) are wrapped so the consumer -receives the agnostic payload, not the DOM event. `undefined` values are -dropped, and any key not in the map passes through unchanged — so a binding the -renderer already understands needs no entry. +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](./src/normalize.ts). --- -## `mergeProps` — combine consumer props with the component's props +## `mergeProps` — consumer props + component props -When a consumer spreads their own props onto the same element the component -controls (``), the two prop sets have to -merge sensibly. `mergeProps(consumer, library)` does it the Radix/Ark way: +When a consumer spreads their own props onto an element the component controls +(``), merge them: ```ts -const finalProps = mergeProps(consumerProps, normalize(api.triggerProps)) +const finalProps = mergeProps(ownProps, normalize(api.triggerProps)) ``` -- **Event handlers are chained, consumer-first.** Both run, the consumer's - before the library's — **but if the consumer's handler marks the event - `defaultPrevented`, the library handler is skipped.** So a consumer can veto the - component's behavior (e.g. prevent a click from toggling) by calling - `e.preventDefault()`. (A key counts as a handler when it's `on` + an uppercase - letter, e.g. `onClick`, `onKeyDown`.) -- **`style` is merged, not overwritten.** If both sides set `style`, the result is - an array `[consumerStyle, libraryStyle]` (the React array-style form; the later - entry wins on conflicting keys). If only one side sets it, that one is kept. -- **`className` is concatenated** with a single space and trimmed at the edges - (`'a b'` + `'c'` → `'a b c'`). Inner spacing is preserved verbatim; the concat - only applies when _both_ sides are strings. -- **Everything else: library wins.** A plain attr the component sets (`id`, - `role`, `aria-*`) overrides the consumer's — the component owns its semantics. - -If the consumer passes no props, the library props are returned as-is. +- **Handlers chain, consumer-first** — both run, unless the consumer's handler + marks the event `defaultPrevented`, which **skips the library handler**. A key + counts as a handler when it's `on` + an uppercase letter (`onClick`, `onKeyDown`). +- **`style` merges** — when both sides set it, the result is the React + array-style form `[consumerStyle, libraryStyle]` (later entry wins per key). +- **`className` concatenates** — `'a b'` + `'c'` → `'a b c'` (only when both + sides are strings). +- **Everything else: library wins** — the component owns its semantics (`id`, + `role`, `aria-*`). --- @@ -256,10 +260,9 @@ If the consumer passes no props, the library props are returned as-is. | Export | What it is | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `useMachine(config, connect, effects, props)` | the bridge hook — build once + lifecycle + run the component effects + `useSyncExternalStore`; returns `{ api, machine }` | +| `useMachine(config, connect, effects, props)` | the bridge hook — build once + lifecycle + component effects + subscribe; returns `{ api, machine }` | | `useSelector(machine, selector, isEqual?)` | fine-grained subscription to a derived slice (`O(readers)`) | | `normalize(bindings)` | agnostic bindings → DOM/ARIA props | | `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; `style`/`className` merged; else library wins) | -| `ComponentEffect` | `[ (machine, props) => cleanup, (keyof P)[] ]` — one substrate effect + its prop deps | -| `ComponentEffects` | `ComponentEffect[]` — a component's effect list (static module constant) | +| `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 | From df532c81d3bd2c0ddeebf4ff368ab52e81bff47f Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Tue, 14 Jul 2026 18:25:07 +0200 Subject: [PATCH 3/3] docs: align website react pages with the README, add native README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - website/react.mdx: fix invalid string-shorthand transitions (the engine takes { target } objects, not strings — a bare string silently no-ops), author the config via setup.infer().createMachine, drop the unused closeOnEscape context seed, replace the normalize table with a taste + link to normalize.ts so it can't go stale, strengthen the useSelector isEqual warning, add the ComponentEffect list rules. - website/react-native.mdx: same normalize taste + link treatment. - website/comparison.mdx: same string-shorthand fix. - packages/native/README.md: the package had no README (blank npm page); add one mirroring the react README — re-export framing, a press-driven disclosure quick start, BackHandler ComponentEffect, RN normalize + mergeProps reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/native/README.md | 191 ++++++++++++++++++ website/src/content/docs/comparison.mdx | 4 +- .../src/content/docs/libs/react-native.mdx | 20 +- website/src/content/docs/libs/react.mdx | 42 ++-- 4 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 packages/native/README.md diff --git a/packages/native/README.md b/packages/native/README.md new file mode 100644 index 0000000..21fd010 --- /dev/null +++ b/packages/native/README.md @@ -0,0 +1,191 @@ +# `@dunky.dev/native-state-machine` + +The **React Native 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 React Native edge that runs it, and it's mostly the +[react package](../react/README.md): `useMachine`, `useSelector`, and the +`ComponentEffect` type are **re-exported unchanged** (React Native uses the +same React renderer). What's native here is the translation layer: + +1. **`normalize`** — translate the machine's agnostic bindings (`onPress`, + `expanded`) into React Native props (`onPress`, `accessibilityState`). +2. **`mergeProps`** — merge the consumer's props with the component's, + RN-aware (style arrays). + +``` + core (agnostic) + | + | config + connect() behavior + snapshot -> view api + | + v + this package (React Native) + | + | useMachine re-exported from the react package + | | + | v + | api the view surface instructions + | | + | v + | normalize() RN props / accessibility* + | + v + +``` + +## Quick start + +A disclosure, end to end — the behavior (core), the surface (`connect`), and +the React Native component (this package): + +```tsx +import { Pressable, Text, View } from 'react-native' +import { setup } from '@dunky.dev/state-machine' +import { useMachine, normalize } from '@dunky.dev/native-state-machine' + +type DisclosureProps = { defaultOpen?: boolean } + +// 1 — behavior: a plain state machine. No React Native in sight. +const disclosureConfig = (props: DisclosureProps) => + setup.infer().createMachine({ + initial: props.defaultOpen ? 'open' : 'closed', // props seed the machine ONCE + context: {}, + states: { + closed: { on: { toggle: { target: 'open' } } }, + open: { on: { toggle: { target: 'closed' } } }, + }, + }) + +// 2 — connect: machine snapshot -> what the view spreads onto elements. +// Agnostic vocabulary: role / expanded — not RN props yet. +const connectDisclosure = ({ state, send }) => { + const open = state === 'open' + return { + open, + triggerProps: { + role: 'button', + expanded: open, + onPress: () => send({ type: 'toggle' }), + }, + } +} + +// 3 — the React Native edge: build + run the machine, render from its api. +const disclosureEffects = [] // no platform effects yet; must be a static constant + +export function Disclosure(props: DisclosureProps) { + const { api } = useMachine(disclosureConfig, connectDisclosure, disclosureEffects, props) + return ( + + + Details + + {api.open && Hidden content} + + ) +} +``` + +What happened: + +- `useMachine` built the machine and connector **once** (the first render's + props seeded the initial state), started it on mount, stops it on unmount. +- The component re-renders only when the `connect()` output actually changes. +- `normalize` turned `role` / `expanded` into `accessibilityRole` / + `accessibilityState.expanded` — the **same** config and `connect` drive the + web through the react package's `normalize`. Only this edge differs. + +--- + +## `useMachine`, `useSelector`, `ComponentEffect` — the shared edge + +Re-exported directly from +[`@dunky.dev/react-state-machine`](../react/README.md) — identical on both +platforms. See the react README for the full reference (build-once semantics, +prop flow, fine-grained subscription, the effect rules). + +The one thing that changes on RN is the _transport_ inside a +`ComponentEffect`: the platform API differs, the shape doesn't. The web +dialog's Escape listener becomes a back-button listener: + +```ts +import { BackHandler } from 'react-native' +import type { ComponentEffect } from '@dunky.dev/native-state-machine' + +type DialogEffect = ComponentEffect + +/** Back-button-to-close — the RN transport for the same close decision. */ +const onBackButton: DialogEffect = [ + (machine, props) => { + if (!props.closeOnBackButton) return + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + machine.send({ type: 'close' }) + return true // consume: prevent default back navigation + }) + return () => sub.remove() + }, + ['closeOnBackButton'], // re-run only when this prop changes +] + +export const dialogEffects = [onBackButton] +``` + +The machine receives the same `send({ type: 'close' })` as the web version — +it has no idea a hardware button exists. + +--- + +## `normalize` — agnostic bindings → React Native props + +`connect` returns substrate-agnostic bindings; `normalize` translates them +into React Native's prop vocabulary: + +```ts +const rnProps = normalize(api.triggerProps) +// { onPress, accessibilityRole, accessibilityState: { expanded }, ... } +``` + +The machine binding maps handlers (`onPress` → `onPress`, `onPointerDown` → +`onPressIn`, `onContextMenu` → `onLongPress`), accessibility props +(`labelledBy` → `accessibilityLabelledBy`, `role` → `accessibilityRole`, +`id` → `nativeID`), and accessibility state (`expanded` / `checked` / … fold +into `accessibilityState`, `valueMin/Max/Now/Text` into `accessibilityValue`). +[Check out the full mapping here](./src/normalize.ts). + +Bindings with no RN equivalent (hover, `onKeyDown`, `onWheel`, most ARIA-only +attrs) are **silently dropped** rather than passed as invalid props. +`undefined` values are dropped; unknown keys pass through unchanged. + +--- + +## `mergeProps` — consumer props + component props + +When a consumer spreads their own props onto an element the component +controls: + +```tsx + +``` + +- **Handlers chain, consumer-first** — both run, unless the consumer's handler + marks the event `defaultPrevented`, which skips the library handler (the + consumer's veto). +- **`style` merges** — both sides set it → the array form + `[consumerStyle, libraryStyle]` (RN accepts style arrays natively; later + entry wins per key). +- **Everything else: library wins** — the component owns its semantics. + +No consumer props → the library props are returned as-is. + +--- + +## API + +| Export | What it is | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `useMachine(config, connect, effects, props)` | re-exported from [`@dunky.dev/react-state-machine`](../react/README.md) — the bridge hook | +| `useSelector(machine, selector, isEqual?)` | re-exported — fine-grained subscription to a derived slice | +| `normalize(bindings)` | agnostic bindings → React Native props (`accessibility*`, `nativeID`, press handlers) | +| `mergeProps(consumer, library)` | merge consumer + component props (handlers chained w/ `defaultPrevented` veto; RN style-array merge) | +| `ComponentEffect` | re-exported — `[ (machine, props) => cleanup, (keyof P)[] ]`; pass a static list of them | +| `Bindings` | `Record` — the loose shape `normalize` accepts | diff --git a/website/src/content/docs/comparison.mdx b/website/src/content/docs/comparison.mdx index ff4c668..0ac9c28 100644 --- a/website/src/content/docs/comparison.mdx +++ b/website/src/content/docs/comparison.mdx @@ -122,8 +122,8 @@ const toggle = machine({ initial: 'closed', context: {}, states: { - closed: { on: { toggle: 'open' } }, - open: { on: { toggle: 'closed' } }, + closed: { on: { toggle: { target: 'open' } } }, + open: { on: { toggle: { target: 'closed' } } }, }, }) diff --git a/website/src/content/docs/libs/react-native.mdx b/website/src/content/docs/libs/react-native.mdx index 2d3717b..122a6bd 100644 --- a/website/src/content/docs/libs/react-native.mdx +++ b/website/src/content/docs/libs/react-native.mdx @@ -26,20 +26,12 @@ import { normalize } from '@dunky.dev/native-state-machine' ; ``` -| Binding | React Native prop | -| ----------------------------------------------- | ----------------------------------------------------------- | -| `onPress` | `onPress` | -| `onPointerDown` | `onPressIn` | -| `onFocus` / `onBlur` | `onFocus` / `onBlur` | -| `onPointerEnter/Leave/Move` | **dropped** (no RN analog; use focus or long-press instead) | -| `onKeyDown` | **dropped** (no RN analog) | -| `describedBy` / `labelledBy` | `accessibilityLabelledBy` | -| `role` | `accessibilityRole` | -| `id` | `nativeID` | -| `expanded` / `selected` / `disabled` / `hidden` | `accessibilityState.expanded/selected/disabled/hidden` | -| `focusable` | `focusable` | - -Bindings with no RN equivalent are silently dropped rather than passed as invalid props. +The machine binding maps handlers (`onPress` → `onPress`, `onPointerDown` → `onPressIn`), +accessibility props (`labelledBy` → `accessibilityLabelledBy`, `role` → `accessibilityRole`, +`id` → `nativeID`), and accessibility state (`expanded` → `accessibilityState.expanded`). +[Check out the full mapping here](https://github.com/dunky-dev/state-machine/blob/main/packages/native/src/normalize.ts). + +Bindings with no RN equivalent (hover, `onKeyDown`, `onWheel`) are silently dropped rather than passed as invalid props. ## `mergeProps` diff --git a/website/src/content/docs/libs/react.mdx b/website/src/content/docs/libs/react.mdx index f087e71..ad22d65 100644 --- a/website/src/content/docs/libs/react.mdx +++ b/website/src/content/docs/libs/react.mdx @@ -11,7 +11,7 @@ The React package is a thin edge layer. Behavior lives in the core machine and t ## `useMachine` -The one bridge hook. Every component calls it with the four agnostic pieces and gets back the view API: +The one bridge hook. Every component calls it with the agnostic pieces and gets back `{ api, machine }` — the view API to render from, and the running service (for `send` and `useSelector`): ```tsx import { useMachine, normalize } from '@dunky.dev/react-state-machine' @@ -48,10 +48,10 @@ Those three values are where the dialog's behavior actually lives, and none of i ```ts // dialog.ts: plain functions, no React -import type { Connect } from '@dunky.dev/state-machine' +import { setup, type Connect } from '@dunky.dev/state-machine' type State = 'closed' | 'open' -type Context = { closeOnEscape: boolean } +type Context = {} type Event = { type: 'open' } | { type: 'close' } type Api = { isOpen: boolean @@ -59,14 +59,15 @@ type Api = { contentProps: object } -export const createDialogConfig = (props: DialogProps) => ({ - initial: props.open ? 'open' : 'closed', - context: { closeOnEscape: props.closeOnEscape ?? true }, - states: { - closed: { on: { open: 'open' } }, - open: { on: { close: 'closed' } }, - }, -}) +export const createDialogConfig = (props: DialogProps) => + setup.infer().createMachine({ + initial: props.open ? 'open' : 'closed', // props seed the machine ONCE + context: {}, + states: { + closed: { on: { open: { target: 'open' } } }, + open: { on: { close: { target: 'closed' } } }, + }, + }) export const connectDialog: Connect = ({ state, @@ -94,16 +95,9 @@ normalize(api.triggerProps) // { onClick, aria-expanded, role, tabIndex, ... } ``` -| Binding | DOM prop | -| ----------------------------------------------- | ------------------------------------------------------------------- | -| `onPress` | `onClick` | -| `onPointerEnter/Leave/Move/Down` | same name | -| `onFocus` / `onBlur` / `onKeyDown` | same name | -| `describedBy` | `aria-describedby` | -| `labelledBy` | `aria-labelledby` | -| `expanded` / `selected` / `disabled` / `hidden` | `aria-expanded` / `aria-selected` / `aria-disabled` / `aria-hidden` | -| `focusable` | `tabIndex` (`true → 0`, `false → -1`) | -| `role` / `id` | `role` / `id` | +The 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/react/src/normalize.ts). `undefined` values are dropped. Unknown keys pass through unchanged. @@ -120,7 +114,7 @@ When a consumer spreads their own props onto the same element the component cont - **`className` is concatenated** with a space. - **Everything else: component wins** (`id`, `role`, `aria-*`). -## `useSelector`: fine-grained leaf subscription +## `useSelector`: fine-grained subscription For a leaf component that should only re-render when one slice of the machine changes (useful when a single machine drives many rows and each row should only wake for its own value): @@ -134,7 +128,7 @@ function Row({ machine, value }) { } ``` -For object selections, pass a custom equality function to avoid re-renders on reference changes: +A selector returning a fresh object/array each call **must** pass a custom equality function — otherwise every read is a "new" value and the component re-renders in a loop. Prefer selecting primitives; reach for `isEqual` when you genuinely need a composite: ```tsx const pos = useSelector( @@ -169,6 +163,8 @@ const onEscapeKey: Effect = [ export const dialogEffects = [onEscapeKey] ``` +Two rules: declare the list once, at module level — each entry becomes a `useEffect`, and React forbids a changing number of hooks — and name the prop deps (typed `(keyof Props)[]`), so each effect re-runs only when its own props change. + The machine only receives `send({ type: 'close' })`; it has no idea a keyboard event exists. The React Native version of this same dialog swaps the DOM `keydown` listener for a `BackHandler` — the machine is unchanged. See [React Native](/libs/react-native) for that side. See [Effects](/api/effects) for the full mental model of where each type of effect lives.