diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 0b0ebbc..740db21 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,5 @@ { - "*.{ts,tsx}": ["oxlint --fix", "oxfmt"], - "*.astro": ["oxlint --fix"], + "*.{ts,tsx}": ["oxlint --fix --no-error-on-unmatched-pattern", "oxfmt"], + "*.astro": ["oxlint --fix --no-error-on-unmatched-pattern"], "*.{md,mdx}": ["oxfmt"] } diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts index ead4522..5a4f8a4 100644 --- a/packages/core/src/actions.ts +++ b/packages/core/src/actions.ts @@ -9,39 +9,19 @@ export type Patch $.setContext(...)` wrapper, so a patch reads as data: * - * actions: act({ pressed: true, ripple: true }) // one or many fields - * actions: act($ => ({ n: $.context.n + 1 })) // derived from params - * actions: act({ touched: true }, $ => ({ n: $.context.n + 1 })) // sequential + * actions: act({ pressed: true }) + * actions: act($ => ({ n: $.context.n + 1 })) * - * Each arg is a static patch or a `$ => patch` fn. Multiple patches are applied - * **in order**, each via its own `setContext` — so a later patch fn sees the - * writes of the earlier ones. `act` returns a normal `Action`, so it slots - * anywhere an action does: a transition's `actions`, an `entry`/`exit` list, or a - * `oneOf` branch's `actions`. It only ever WRITES — `target`/`guard` live on the - * surrounding transition, never on `act`. - * - * TYPES — `Context` is `NoInfer`, so it is NOT read off the patch argument; it - * flows from the slot the action lands in (the `Actions` position in - * a transition / `entry` / `exit` / `oneOf`). That means a bare `act({ x: 1 })` - * or `act($ => ({ x: $.context.x + 1 }))` is fully typed against the surrounding - * config — a wrong field or value errors at the call site, with NO per-call - * generics, even when the config declares `computed` (the `Computed` param also - * comes from the slot). The `$` in a function patch is likewise slot-typed. - * - * The only case still needing an explicit annotation is a STANDALONE `act(...)` - * with no contextual slot (e.g. assigned to a bare `const` before being placed), - * where there's nothing for `Context` to flow from — write - * `act(...)` there. Inside a config it's never needed. + * Multiple patches apply in order; later fn patches see earlier writes. + * `Context` is `NoInfer` — it flows from the surrounding slot, not the argument, + * so no per-call generics are needed inside a config. Only a standalone `act(...)` + * outside any config slot needs explicit `act(...)`. */ export function act, Send = Event>( ...patches: Array, Event, Computed, Send>> ): Action { return params => { - // Sequential by construction: setContext mutates the context object in - // place (its identity never changes), so a later patch fn reading - // `params.context` sees the earlier patches' writes — no local tracking. for (const patch of patches) { params.setContext(typeof patch === 'function' ? patch(params) : patch) } @@ -49,26 +29,8 @@ export function act $.context.variant === 'primary', actions: act({ shadow: 'lg' }) }, - * { guard: $ => $.context.variant === 'ghost', actions: act({ shadow: 'none' }) }, - * { actions: act({ shadow: 'md' }) }, // guardless = fallback - * ), - * ] + * `oneOf(...branches)` — runs the first branch whose guard passes; the rest are skipped. + * A guardless branch always matches — use it last as the fallback. */ export function oneOf>( ...branches: Array> @@ -76,15 +38,6 @@ export function oneOf( action: ActionArg, ): action is OneOf { @@ -95,17 +48,6 @@ export function isOneOf( ) } -// ----------------------------------------------------------------------------- -// Running actions -// ----------------------------------------------------------------------------- - -/** - * Everything `runAction(s)` needs from the host machine to run an action: the - * named registries (actions + guards, the latter for `oneOf` branch guards) and - * live accessors for the params an action receives. Reads are LIVE — `context()` - * and `computed()` re-read each call, so an action (and a later action in the - * same list) always sees the current context, never a stale snapshot. - */ export interface ActionHost { actions: Record> | undefined guards: Record> | undefined @@ -115,12 +57,6 @@ export interface ActionHost { send: (event: Event) => void } -/** - * Run one action arg for `event`. A `oneOf(...)` expands to its first - * guard-passing branch (guardless = fallback); an inline fn runs directly; a - * registered name resolves against `host.actions` (missing → throw in dev, warn - * in prod). The action receives live context/computed + setContext/send. - */ export function runAction( host: ActionHost, action: ActionArg, @@ -134,7 +70,6 @@ export function runAction( if (branch) runActions(host, branch.actions, event) return } - // past the oneOf guard, `action` is an inline fn or a registered name const named = action as Exclude> const fn = typeof named === 'function' ? named : host.actions?.[named] if (!fn) { @@ -152,10 +87,6 @@ export function runAction( }) } -/** - * Run an `actions` / `entry` / `exit` slot: a single action or a list, in order. - * Undefined slot is a no-op. - */ export function runActions( host: ActionHost, actions: Actions | undefined, diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts index f8b3ee6..cd39160 100644 --- a/packages/core/src/compose.ts +++ b/packages/core/src/compose.ts @@ -20,12 +20,8 @@ export interface Composition> { */ sync: (reaction: () => void) => () => void /** - * Derive one value-deduped Selection across the members. The selector reads - * from any members; it RE-EVALUATES whenever ANY member changes (it subscribes - * to each member's coarse bus) and fires its listener only when the selected - * VALUE changes. So the listener is O(changed value), but the re-eval pass is - * O(members) per change — not field-level dependency tracking. Read `.value` - * or `.subscribe(listener, equals?)`. + * Derive a value-deduped Selection across members. Re-evaluates on any member change; + * fires only when the selected value changes. */ combine: (selector: () => Value) => Selection } @@ -63,9 +59,6 @@ export function compose>( get value() { return selector() }, - // Re-evaluate the selector whenever ANY member changes (subscribe to each - // member's coarse bus, like `sync`), and fire only on a real change. The - // selector reads across members; their changes drive it. No fire on setup. subscribe(listener: (value: Value) => void, equals: EqualityFn = Object.is) { let prev = selector() const onChange = () => { diff --git a/packages/core/src/computed.ts b/packages/core/src/computed.ts index 73b2cb8..0278db1 100644 --- a/packages/core/src/computed.ts +++ b/packages/core/src/computed.ts @@ -1,33 +1,16 @@ import type { ComputedDefs } from './types' -/** - * Live accessors into the host machine. Computed defs read the CURRENT context / - * computed bag / state through these — never a snapshot. Supplied once at - * install. - */ export interface ComputedHost { - /** The live context object (re-read every access). */ context: () => Context - /** The live computed bag (so a computed can depend on another computed). */ computed: () => Computed - /** The live state value (reading it makes the lifecycle a tracked dependency). */ state: () => State } /** - * Install the computed bag onto `target` with read-key tracking: each def records - * exactly which context keys and which other computeds it read (via a tracking - * proxy on first/every recompute), and only recomputes when one of THOSE inputs - * changed — not on any context write. This keeps signal-level laziness (an - * expensive computed in a churny machine doesn't recompute when an unrelated - * field moves) without per-field reactive cells. Chains resolve transitively and - * glitch-free: a dep on another computed is checked by reading that computed - * (which lazily recomputes itself first if stale). - * - * Defined as a property installer (rather than returning a new object) so the - * machine can hand in the SAME object it exposes as `this.computed` — the - * `host.computed()` accessor and the installed getters then reference one bag, - * which is what makes computed→computed chains resolve in place. + * Install computed getters on `target` with read-key tracking: each def records which + * context/computed keys it read and recomputes only when one of those inputs changed. + * Installs onto the SAME object the machine exposes as `this.computed` so computed→computed + * chains resolve in place. */ export function installComputed( target: Computed, @@ -39,8 +22,6 @@ export function installComputed = {} @@ -48,14 +29,10 @@ export function installComputed | null = null let computedRead: Set | null = null - // set true during a recompute so reading `params.state` records the - // dependency; nulled outside so a stale-check read never records. + // True during recompute so reading `params.state` records a state dependency. let tracking = false const trackedCtx = new Proxy({} as Record, { get: (_t, p: string) => { @@ -71,14 +48,12 @@ export function installComputed { - // if the def read `state`, the lifecycle is a dependency too if (readState && stateSnapshot !== host.state()) return true for (const dk of ctxDeps) { if (!Object.is(ctxSnapshot[dk], (host.context() as Record)[dk])) return true } - // reading a computed dep below resolves ITS staleness first, so a - // transitive change surfaces as a value difference here + // Reading a computed dep resolves ITS staleness first — transitive changes surface here. for (const dk of computedDeps) { if (!Object.is(computedSnapshot[dk], (host.computed() as Record)[dk])) return true @@ -90,7 +65,6 @@ export function installComputed { if (computedOnce && !stale()) return cachedValue - // swap in fresh read-sets, recompute under the (reused) proxies const cr = new Set() const compr = new Set() ctxRead = cr @@ -101,8 +75,6 @@ export function installComputed { let props = initialProps - // Lazily memoized snapshot. `dirty` is set on any machine change or props - // change; the next `snapshot` read rebuilds and caches. Stable identity while - // clean → safe as a useSyncExternalStore getSnapshot. let cached: Api let dirty = true const rebuild = (): Api => @@ -58,23 +45,15 @@ export function connector< return cached } - // Coarse listeners on this connector. A machine change or a props change marks - // the snapshot dirty and wakes them. We subscribe to the machine once and fan - // its notifications (plus props changes) out to connector subscribers. const listeners = new Set<() => void>() const wake = () => { dirty = true for (const l of [...listeners]) l() } - // The machine's coarse subscribe drives snapshot invalidation. Held for the - // connector's life and released by destroy() (passive — the bridge still owns - // start/stop). const offWake = service.subscribe(wake) - // Reactions (declared state-change → prop-callback) live exactly as long as the - // machine runs: wired on every start(), torn down on stop(). Hooking the - // machine's own lifecycle (not the connector's construction) means a restart — - // notably React StrictMode's mount→unmount→mount — cleanly re-establishes them. + // Wire reactions on start(), tear them down on stop() — so a restart (e.g. StrictMode + // mount→unmount→mount) cleanly re-establishes them. let reactionOffs: Array<() => void> = [] const offStart = service.onStart(() => { reactionOffs = (connect.reactions ?? []).map(([selector, callback]) => { @@ -97,9 +76,6 @@ export function connector< }, select: service.select, setProps(next) { - // Value-dedup: consumers often rebuild an equal props object every render - // (new identity, same values). Skip the wake when shallow-equal so an equal - // re-render doesn't needlessly recompute the snapshot or wake subscribers. if (shallowEqual(props, next)) return props = next wake() diff --git a/packages/core/src/guards.ts b/packages/core/src/guards.ts index cfa65cb..920134a 100644 --- a/packages/core/src/guards.ts +++ b/packages/core/src/guards.ts @@ -1,13 +1,6 @@ import { isDev } from './constants' import type { Guard, GuardArg, GuardParams } from './types' -/** - * Resolve a guard arg — an inline predicate or a registered name — against - * `params`, looking names up in `registry`. The single channel the combinators - * (`and`/`or`/`not`) and the runtime both go through, so `and('a', not(b))` - * resolves names against one registry. Missing name → throw in dev, warn + false - * in prod. - */ export function resolveGuard( guard: GuardArg, params: GuardParams, @@ -24,12 +17,6 @@ export function resolveGuard( return fn(params) } -/** - * Build the params a guard/delay reads for one event: context + event + computed, - * plus a self-referential `guard` that resolves nested guards against the same - * params and registry (the channel the combinators use). The `params` object is - * referenced inside its own `guard` member, so it's assembled then closed over. - */ export function makeGuardParams( context: Context, event: Event, diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 1f17cf3..d3863bc 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1,12 +1,3 @@ -/** - * Implemented as a class so the engine logic lives on the prototype (one shared - * copy) and each instance holds only data — the per-machine footprint is flat in - * field/state count (no per-field reactive cell, no per-instance closure tree). - * The reactivity kernel is a tiny coarse bus: a write (context or state change) - * bumps `version` and notifies every listener; `select` re-evaluates + value-compares - * so it fires only on a real change (O(changed) at the listener), and `computed` - * memoizes by snapshotting the inputs it actually read (see ./computed). - */ import { type ActionHost, runActions } from './actions' import { installComputed } from './computed' import { isDev, MACHINE_INIT, MAX_DRAIN } from './constants' @@ -23,9 +14,7 @@ import type { TransitionConfig, } from './types' -// Per-`states` tag-set cache: a state's tags depend only on the STATIC config, so -// derive them once per states-map and share across every machine built from it — -// keeps per-instance memory flat as state count grows. Keyed by the states object. +// Tags depend only on static config — derive once per states-map, share across instances. const tagsCache = new WeakMap>>() function tagsForStates( states: Record, @@ -49,60 +38,40 @@ class MachineClass< ctx: Context stateValue: State tagsOf: Record> - // Monotonic change counter, bumped on every notify. Lets `computed` memoize - // (recompute only when something changed since its last read) without per-field - // dependency tracking. + // Monotonic counter bumped on every notify — lets computed memoize without per-field tracking. version = 0 - // The coarse notification bus: listeners under subscribe + select. Mutated only - // through busAdd/busDelete so `busSnapshot` (the array we iterate in bump) is - // re-derived only when membership changes — steady-state notifies allocate - // nothing, while still iterating a stable copy (a listener ADDED during notify - // first fires on the next pass; one REMOVED during notify is skipped - // immediately — see bump). + // Coarse notification bus. Mutated through busAdd/busDelete so the iteration snapshot + // (busSnapshot) is only re-derived when membership changes — steady-state notifies allocate nothing. bus = new Set<() => void>() busSnapshot: Array<() => void> = [] busDirty = false - // The run-to-completion queue. Holds events to dispatch AND deferred jobs (a - // watcher's action run) — both wait for the in-flight transition to finish. - // Discriminated by typeof: an event is an object, a job is a function. + // Run-to-completion queue. Events (objects) and deferred jobs (functions) both wait for + // the in-flight transition to finish before running. queue: Array void)> = [] flushing = false running = false - // Bumped on every state ENTRY. An `after` timer captures the generation it was - // scheduled in; if the machine exits and re-enters the same state before a - // deferred timer dispatches, the generation no longer matches and the stale - // timer is ignored — closing the exit-and-re-enter TOCTOU window that a plain - // `stateValue === scheduledIn` check would miss. + // Bumped on every state ENTRY. An `after` timer captures the generation at schedule time; + // if the machine exits and re-enters the same state before the timer fires, the generation + // no longer matches and the stale timer is ignored. entryCounter = 0 stateCleanups: Array<() => void> = [] watcherCleanups: Array<() => void> = [] - // lazily created — a machine with no reactions/connector pays nothing + // Lazily created — a machine with no connector pays nothing. startListeners: Set<() => void> | null = null stopListeners: Set<() => void> | null = null computed: Computed - // stable bound refs handed to actions/effects (the only per-instance closures) setContext: (patch: Partial) => void send: (event: Event) => void - // live params + named registries that runAction(s) read (built once in the ctor) actionHost: ActionHost constructor(config: TransitionConfig) { this.config = config - // Own copy from birth, identity PERMANENT: writes mutate it in place, so a - // reference captured anywhere (an effect's closure, action params) is a - // live view forever; the config's object is never mutated. This replaces - // copy-on-write — its one-time reference swap silently stranded refs - // captured before the first write, and the sharing it bought was ~40 B per - // idle machine on a component-sized context (see the memory bench). + // Own copy from birth — identity never changes, writes mutate in place. + // Refs captured in effects/actions always see the live context. this.ctx = { ...config.context } this.stateValue = config.initial - // shared per-config tag sets (not rebuilt per instance) — see tagsForStates this.tagsOf = tagsForStates(config.states) - // Computed bag with read-key tracking — see ./computed. The host accessors - // read this machine's live context / computed / state. `target` IS - // `this.computed`, so a computed→computed dep resolves in place against the - // same bag. this.computed = {} as Computed if (config.computed) { installComputed(this.computed, config.computed, { @@ -112,8 +81,6 @@ class MachineClass< }) } - // Live action params (context/computed re-read each run) + the named - // registries. Built once; `runAction(s)` close over it. this.actionHost = { actions: config.implementations?.actions, guards: config.implementations?.guards, @@ -124,7 +91,6 @@ class MachineClass< } this.setContext = patch => { - // dedup: a no-op write must not notify (Object.is, early-out) let changed = false for (const key in patch) { if (!Object.is(this.ctx[key], patch[key])) { @@ -139,8 +105,6 @@ class MachineClass< this.send = event => this.doSend(event) } - // ---- kernel notify ---- - // Bus membership goes through these so the iteration snapshot can be cached. private busAdd(listener: () => void): void { this.bus.add(listener) this.busDirty = true @@ -152,26 +116,15 @@ class MachineClass< private bump(): void { this.version++ - // Iterate a STABLE snapshot, not the live Set, so a listener added during - // notify first fires on the NEXT pass. The snapshot is re-derived only when - // membership changed since the last notify — steady-state notifies allocate - // nothing. The live-membership check skips listeners REMOVED mid-pass: - // unsubscribe takes effect immediately, because firing a removed listener - // (e.g. a reaction whose teardown just ran) breaks the unsubscribe contract. + // Iterate a stable snapshot so mid-pass (un)subscribes take effect after the current pass. + // Skip the has() guard in the steady state; flip to checked mode if membership changes mid-pass. if (this.busDirty) { this.busSnapshot = [...this.bus] this.busDirty = false } - // Until a listener (un)subscribes mid-pass, the snapshot IS live membership, - // so the per-listener has() check is pure overhead — skip it. The instant a - // listener mutates the bus during the pass, busAdd/busDelete flip busDirty and - // we resume checking for the remainder (the removed-mid-pass guarantee). This - // keeps the fan-out path (K selectors, one write) at K calls + 0 Set lookups - // in the steady state. for (const l of this.busSnapshot) if (!this.busDirty || this.bus.has(l)) l() } - // ---- reads ---- get state(): State { return this.stateValue } @@ -191,14 +144,7 @@ class MachineClass< this.bump() } - // ---- guards / resolution ---- - // A guard resolver bound to THIS event's params + the config's guard registry, - // handed to `resolve` (transition selection) so guard names resolve against the - // runtime's single registry. Params are built LAZILY: a candidate list whose - // winner is guardless (the common UI case — `open → closed` on a click) never - // touches a guard, so `makeGuardParams` (an object + a self-referential closure) - // is deferred until the first guard actually runs, and memoized across the rest - // of the list. The hottest send shape skips it entirely (see `selectTransition`). + // Guard params are built lazily — guardless transitions (the common case) never allocate them. private resolverFor(event: Event): (guard: GuardArg) => boolean { let params: ReturnType> | undefined return guard => @@ -209,11 +155,7 @@ class MachineClass< this.config.implementations?.guards, )).guard(guard) } - // Pick the transition for an event, fast-pathing the dominant shape: a single - // guardless `Transition` object resolves to itself with NO resolver built and - // NO array allocated (`resolve` wraps a bare entry in a 1-element array). Only a - // fn entry, an array (fallthrough), or a guarded object falls through to the - // general `resolve` — which then lazily builds the resolver above. + // Fast-path: a single guardless object resolves to itself with no resolver or array allocated. private selectTransition( entry: ReturnType>, event: Event, @@ -226,9 +168,6 @@ class MachineClass< runActions(this.actionHost, actions, event) } - // ---- transition: exit (cleanup effects + exit actions) → transition actions → - // switch → entry actions + start effects. Self-transition (no state change) - // runs actions only, skipping exit/entry. Effect boot/cleanup only while running. private applyTransition(t: Transition, event: Event): void { const cur = this.stateValue const next = t.target ?? cur @@ -244,10 +183,7 @@ class MachineClass< if (this.running) this.startEffects(next, event) } } - // ---- queue: run-to-completion ---- - // Push an item and drain, unless a drain is already in flight — a re-entrant - // enqueue (a send from an action, a watcher detecting a mid-transition write) - // waits until the current transition fully finishes. + // Re-entrant enqueues (send from an action, watcher mid-transition) wait for the current drain. private enqueue(item: Event | (() => void)): void { this.queue.push(item) if (this.flushing) return @@ -258,8 +194,6 @@ class MachineClass< this.flushing = false } } - // Drain until empty, FIFO. The caller owns the `flushing` flag. An event - // resolves + applies a transition; a job (deferred watcher run) just runs. private drainQueue(): void { let ticks = 0 while (this.queue.length) { @@ -282,7 +216,6 @@ class MachineClass< this.enqueue(event) } - // ---- delays / after ---- private resolveDelay(key: string, event: Event): number { const asNum = Number(key) if (!Number.isNaN(asNum)) return asNum @@ -295,12 +228,8 @@ class MachineClass< } return fn(makeGuardParams(this.ctx, event, this.computed, this.config.implementations?.guards)) } - // A fired timer applies the first `after` transition whose guard passes — only - // if still in the scheduling state and still running. If a drain is in flight, - // defer to a microtask so it runs after the current transition completes. private dispatchAfter(scheduledIn: State, key: string, event: Event, generation: number): void { - // Ignore a stale timer: not running, moved to a different state, OR exited and - // re-entered the same state since scheduling (generation changed). + // Stale timer: machine stopped, moved to a different state, or re-entered the same state. if (!this.running || this.stateValue !== scheduledIn || this.entryCounter !== generation) { return } @@ -319,10 +248,7 @@ class MachineClass< } } - // ---- effects: schedule `after` timers, then run state effects; stash cleanups ---- private startEffects(state: State, event: Event): void { - // Each entry is a new generation — a timer scheduled now is bound to it, so a - // later exit+re-enter invalidates a still-pending (deferred) dispatch. const generation = ++this.entryCounter const after = this.config.states[state].after if (after) { @@ -344,8 +270,6 @@ class MachineClass< continue } const cleanup = fn({ - // handing the object itself is safe: its identity never changes (writes - // mutate in place), so an effect's long-lived closures read live values context: this.ctx, setContext: this.setContext, event, @@ -360,11 +284,6 @@ class MachineClass< this.stateCleanups.length = 0 } - // ---- watch: machine-global data reaction. A bus listener re-reads the field - // and, on a real change (no fire on setup), queues the actions through the - // run-to-completion queue — they run AFTER the transition that changed the - // field settles, like an event sent from an action. Cleanups live in their - // OWN list — watchers span the whole run, not a single state. ---- private readField(key: string): unknown { return key in this.ctx ? (this.ctx as Record)[key] @@ -381,11 +300,8 @@ class MachineClass< const next = this.readField(key) if (Object.is(prev, next)) return prev = next - // Defer, don't run: this listener fires inside bump() — mid-transition, - // inside the notify pass. Running actions here would be re-entrant - // (other listeners observe a half-applied transition; a watcher writing - // context recurses into a nested bump, unbounded). The `running` check - // re-runs at job time: a stop() mid-drain drops a pending watcher run. + // Defer: this fires inside bump() (mid-transition). Running actions immediately + // would be re-entrant. The `running` check at job time drops pending runs on stop(). this.enqueue(() => { if (this.running) this.runActions(actions, { type: MACHINE_INIT } as Event) }) @@ -399,15 +315,12 @@ class MachineClass< this.watcherCleanups.length = 0 } - // ---- lifecycle: built stopped; send() works regardless of running (pure - // state), but effects/watchers/timers run only while running. ---- start = (): void => { if (this.running) return this.running = true this.startWatchers() - // Boot the CURRENT state's effects, not the initial state's: stop() doesn't - // reset stateValue and send() works while stopped, so a (re)start may find - // the machine in any state (e.g. StrictMode's mount→unmount→mount). + // Boot the CURRENT state's effects — stop() doesn't reset stateValue, so a + // restart (e.g. StrictMode mount→unmount→mount) may be in any state. this.startEffects(this.stateValue, { type: MACHINE_INIT } as Event) if (this.startListeners) for (const fn of this.startListeners) fn() } @@ -420,7 +333,7 @@ class MachineClass< } onStart = (fn: () => void): (() => void) => { ;(this.startListeners ??= new Set()).add(fn) - if (this.running) fn() // already running → run now so a late registrant doesn't miss it + if (this.running) fn() // already running — fire immediately so late registrants don't miss it return () => this.startListeners?.delete(fn) } onStop = (fn: () => void): (() => void) => { @@ -428,15 +341,11 @@ class MachineClass< return () => this.stopListeners?.delete(fn) } - // ---- subscription: coarse (any change) ---- subscribe = (listener: () => void): (() => void) => { this.busAdd(listener) return () => this.busDelete(listener) } - // A Selection re-evaluates its selector on every bus notify and fires its - // listener only when the selected value changes (Object.is default / equals). - // `value` is a plain eval. No fire on subscribe. private makeSelection(selector: () => Value): Selection { const add = this.busAdd.bind(this) const remove = this.busDelete.bind(this) @@ -471,9 +380,6 @@ class MachineClass< } } -/** - * Build a stopped machine service. See the file header for the architecture. - */ export function machine< State extends string, Context extends object, diff --git a/packages/core/src/reaction.ts b/packages/core/src/reaction.ts index b5b7c00..b81367e 100644 --- a/packages/core/src/reaction.ts +++ b/packages/core/src/reaction.ts @@ -1,25 +1,16 @@ import type { Machine, Reaction } from './types' /** - * Identity builder for a single reaction tuple that recovers the - * `selector → callback` value link inference can't get from a bare array. + * Type helper for a reaction tuple — infers `Value` from the selector so the callback is typed. + * A bare `[selector, callback]` in the `reactions` slot collapses `Value` to `any`. * - * Written inline, `[selector, callback]` lands in the `reactions?: Reaction<…, - * any>[]` slot, so `Value` collapses to `any` and the callback's first param is - * untyped. `reaction(...)` instead INFERS `Value` from the selector's return and - * binds it to the callback — a typo in either half errors at the call site. - * - * Curried so the machine generics are fixed once per component (they can't be - * inferred from the tuple) while `Value` is inferred per reaction: + * Curried so machine generics are fixed once while `Value` is inferred per reaction: * * const reaction = makeReaction() * const onOpenChange = reaction( - * m => m.matches('open') || m.matches('closing'), // Value = boolean (inferred) - * (open, props) => props.onOpenChange?.({ open }), // open: boolean + * m => m.matches('open'), + * (open, props) => props.onOpenChange?.({ open }), * ) - * connect.reactions = [onOpenChange] - * - * Returns the tuple unchanged — purely a type-level helper. */ export function makeReaction< State extends string, diff --git a/packages/core/src/setup.ts b/packages/core/src/setup.ts index 90b0e28..bff94fa 100644 --- a/packages/core/src/setup.ts +++ b/packages/core/src/setup.ts @@ -1,18 +1,7 @@ import type { Implementations, TransitionConfig, AnyString } from './types' -/** - * The shared authoring chain, parameterized by the machine types. Both entry - * points (`setup.infer()` and `setup.as<...>()`) return this same object — they - * differ only in whether `Context`/`Event` are inferred from the literal - * (`infer`, both default to `never`) or pinned explicitly (`as`). - */ function chain() { return { - /** - * Build a config directly — no named-impl registries, names left loose. - * Under `setup.infer()`, `State` / `Context` / `Event` are inferred from the - * literal. For checked names, go through `.config(registries)` first. - */ createMachine< State extends string, C extends object = Context, @@ -24,16 +13,6 @@ function chain config>(registries: Registry) { return { - /** - * Build the config with all four name slots checked against the registries - * from `.config(...)`. `initial` / `State` are inferred from `states`; the - * registries are merged into `implementations` so `machine()` resolves the - * names at runtime exactly as before. - * - * The guard/action/effect/delay name unions are inlined (rather than local - * `type` aliases) so this method's inferred signature names no - * function-local types — required by `--isolatedDeclarations`. - */ createMachine( config: Omit< TransitionConfig< @@ -62,59 +41,18 @@ function chain } /** - * `setup` — the authoring entry point, with two symmetric paths that share the - * same `.config(...).createMachine(...)` chain. The first step names the intent: - * - * // infer: types inferred from the literal, no annotations needed - * const cfg = setup.infer().createMachine({ initial, context, states }) - * - * // as: you pin Context / Event, then names are compile-checked - * const { createMachine } = setup.as().config({ ... }) - * createMachine({ ... }) - * - * Why a chain instead of one call? TypeScript has no PARTIAL type-argument - * inference — pass even one type arg and you must pass them all, inferring none. - * Splitting the work across calls gives each its own inference site: - * - * 1. `setup.as()` — pin the machine types (they can't be - * inferred from a registry). `setup.infer()` skips this, leaving them to be - * inferred from the literal at `createMachine`. - * 2. `.config(registries)` — infer the registry object (`const`, so keys stay - * literal); its callbacks are typed from step 1's Ctx/Ev. - * 3. `.createMachine(config)` — the config, with every guard/action/effect/delay - * name now checked + autocompleted against step 2's keys. - * - * The checked chain in full: + * `setup` — authoring entry point. * - * const { createMachine } = setup.as().config({ - * guards: { isOpen: ({ context }) => context.open }, - * actions: { setId: ({ context }) => store.set(context.id) }, - * effects: { track: ({ send }) => store.subscribe(...) }, - * delays: { openDelay: ({ context }) => context.openMs }, - * }) + * // infer: types inferred from the literal + * setup.infer().createMachine({ initial, context, states }) * - * createMachine({ - * initial: 'closed', - * context: { ... }, - * states: { - * open: { - * entry: ['setId'], // ✅ checked against `actions` - * effects: ['track'], // ✅ checked against `effects` - * after: { openDelay: { ... } }, // ✅ checked against `delays` (numbers still ok) - * on: { close: { target: 'closed', guard: 'isOpen' } }, // ✅ checked against `guards` - * }, - * }, - * }) + * // as: pin types, get compile-checked named guards/actions/effects/delays + * setup.as().config({ guards: { … }, actions: { … } }).createMachine({ … }) * - * `createMachine` returns the same `TransitionConfig` shape `machine()` consumes - * (registries merged into `implementations`), so the rest of the pipeline is - * unchanged. + * The chain splits across calls because TypeScript has no partial type-argument inference — + * passing one type arg forces you to pass all. Each call gets its own inference site. */ -// `infer` / `as` are function declarations assembled into the `setup` object, -// so the public surface is `setup.infer()` / `setup.as<...>()`. Both carry an -// explicit return type (`ReturnType>`) because the package is -// built with `--isolatedDeclarations`, which can't infer the chain's complex -// shape for an emitted `.d.ts`. +// Explicit return types required by --isolatedDeclarations (can't infer the chain shape). /** Infer `State` / `Context` / `Event` from the config literal; no annotations. */ function setupInfer(): ReturnType>> { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index bae20ee..6359026 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -20,7 +20,6 @@ export function createStore( get: () => state, set(action) { const patch = typeof action === 'function' ? action(state) : action - // shallow-equal dedup: a no-op write doesn't notify let changed = false for (const k in patch) { if (!Object.is(state[k as keyof T], patch[k as keyof T])) { diff --git a/packages/core/src/transitions.ts b/packages/core/src/transitions.ts index 7e761ba..405ab7d 100644 --- a/packages/core/src/transitions.ts +++ b/packages/core/src/transitions.ts @@ -1,25 +1,6 @@ import type { GuardArg, Transition, TransitionConfig, TransitionEntry } from './types' -/** - * Transition SELECTION: event → which transition fires. The front half of a - * send (the back half — exit/actions/switch/entry/effects — stays in the machine - * as it's intrinsically stateful). Pure over the config + an injected guard - * resolver, so it has no machine coupling and is shared by both send paths (the - * event queue and the `after`-timer dispatch, which otherwise duplicate it). - */ - -/** - * Look up the `on` entry for a live event: the current state's handler first, - * falling back to any-state (`config.on`). Pure config lookup — resolves no - * guards, runs nothing. Returns the raw entry (object / bare fn / array) or - * undefined when nothing here handles this event. - * - * `EventMap` is keyed to the narrow event-type literals, so the entry it yields - * for key `K` narrows `event` to that variant at AUTHORING time — but at RUNTIME - * we index with the broad `event.type`, so we read it back through the union - * `TransitionEntry` (`resolve` re-narrows by matching the actual event). The - * single place that crosses the narrow→broad boundary. - */ +/** Look up the `on` entry for an event: current state first, falling back to `config.on`. */ export function lookupOn< State extends string, Context extends object, @@ -40,15 +21,8 @@ export function lookupOn< } /** - * Pick the winning transition from a raw entry: normalize the three authoring - * forms (object / bare fn / array) to a uniform list, and return the first whose - * guard passes. A bare fn entry is a guardless, targetless transition: normalize - * it to `{ actions: [fn] }` so the one "first passing guard wins" loop covers all - * three forms. Guardless → always matches (so a bare fn is a fallback). - * - * Guard resolution is injected (`resolveGuard`) so this stays free of the - * machine — the caller binds it to the runtime's guard registry + the params for - * this event (built once at the call site, shared across the list). + * Return the first transition whose guard passes. Normalizes the three entry forms + * (object / bare fn / array) to a list; a bare fn becomes `{ actions: [fn] }` (guardless). */ export function resolve( entry: TransitionEntry | undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d15e668..f5f8222 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,7 +1,3 @@ -// ----------------------------------------------------------------------------- -// State -// ----------------------------------------------------------------------------- - /** Per-state node. `tags` groups states so consumers query a tag, not names. */ export interface StateNode { tags?: string[] @@ -14,48 +10,22 @@ export interface State { hasTag: (tag: string) => boolean /** Is the current state exactly `name`? (sugar for state === name) */ matches: (name: T) => boolean - /** - * Move to a new state. Internal to the engine: the transition layer calls - * this; the assembled machine does not expose it (consumers move state via - * `send`). - */ set: (next: T) => void } -// ----------------------------------------------------------------------------- -// Guards -// ----------------------------------------------------------------------------- - /** Everything a guard can read. */ export interface GuardParams> { context: Context event: Event computed: Computed - /** - * Resolve another guard — a registered name or an inline fn — against these - * same params. The channel the combinators use, so `and('a', not(b))` - * resolves names through the runtime's single guard registry. - */ + /** Resolve another guard against these same params — the channel combinators (`and`/`or`/`not`) use. */ guard: (g: GuardArg) => boolean } -// ----------------------------------------------------------------------------- -// Registered-name slots -// ----------------------------------------------------------------------------- -// -// A config references named impls by string: `guard: 'isOpen'`, `entry: ['log']`, -// `effects: ['track']`, `after: { openDelay: … }`. By default each name slot is -// `AnyString` (any name compiles; a typo is caught at runtime). When a config is -// authored through `setup.as().config(...).createMachine(...)`, the builder threads the -// registry's keys into these params so a name is checked + autocompleted against -// what's actually registered — a typo becomes a compile error. - /** - * The loose default for every registered-name slot. `string & {}` — NOT bare - * `string` — so that when a real name union is supplied (`'isOpen' | 'isLocked'`) - * and the slot reads `fn | Names`, the literals stay autocompletable instead of - * being swallowed by a plain `string` in the union. With no names supplied it - * still accepts any string (back-compat). + * Default for every registered-name slot. `string & {}` rather than `string` so + * real name literals stay autocompletable when a union is supplied — bare `string` + * swallows them. */ export type AnyString = string & {} @@ -64,10 +34,7 @@ export type Guard, ) => boolean -/** A guard arg in a transition: an inline predicate or a registered name - * (resolved against implementations.guards). Missing name → throw in dev, - * warn + false in prod. `GuardName` is the set of valid names — any string by - * default (unchecked), the registered guard keys under `setup.as()`. */ +/** An inline guard or a registered name. `GuardName` is `AnyString` by default; narrowed under `setup.as()`. */ export type GuardArg< Context extends object, Event, @@ -75,13 +42,7 @@ export type GuardArg< GuardName extends string = AnyString, > = Guard | GuardName -// ----------------------------------------------------------------------------- -// Transitions -// ----------------------------------------------------------------------------- - -/** A single transition: optional target, optional guard, optional actions. - * `Event` is the (possibly narrowed) incoming event; `Send` is the full event - * union an action may dispatch (defaults to `Event`). */ +/** A single transition. `Event` is the (possibly narrowed) incoming event; `Send` defaults to `Event`. */ export interface Transition< State extends string, Context extends object, @@ -91,9 +52,7 @@ export interface Transition< GuardName extends string = AnyString, ActionName extends string = AnyString, > { - // NoInfer: `target` is checked against the State union (defined by the states - // keys) rather than contributing to inferring it — so a bad target errors at - // the target and autocompletes the declared states, instead of widening State. + // NoInfer: checked against State rather than contributing to inferring it. target?: NoInfer guard?: GuardArg /** Actions to run, in order. A single action or a list. */ @@ -101,17 +60,8 @@ export interface Transition< } /** - * A transition entry, in any of three forms: - * - a {@link Transition} object — "go (+ optionally do)" - * - a bare {@link Action} function — "just do this" (a guardless, - * targetless transition — the terse form for context-only handlers, where - * a `{ actions: [fn] }` wrapper with no `target` would be pure noise) - * - an array of either — fallthrough: the first element whose - * guard passes wins; a bare fn is guardless, so it always matches (use it - * last, as the fallback). Mirrors the rule for a guardless object. - * - * The runtime normalizes a bare fn to `{ actions: [fn] }` (see `resolve`), so - * all three forms run through the same "first passing guard wins" loop. + * A transition entry: an object, a bare action fn (shorthand for `{ actions: [fn] }`), + * or an array of either (fallthrough: first passing guard wins). */ export type TransitionEntry< State extends string, @@ -129,17 +79,7 @@ export type TransitionEntry< | Action > -// ----------------------------------------------------------------------------- -// Actions -// ----------------------------------------------------------------------------- - -/** - * Everything an action can read/use. `Event` is the (possibly narrowed) - * incoming event the action reads; `Send` is the FULL event union you may - * dispatch — under a per-event `on.K` entry, `event` narrows to that variant - * while `send` still accepts any event. `Send` defaults to `Event` so a - * direct/unnarrowed use is unchanged. - */ +/** Everything an action can read/use. `Send` is the full event union (defaults to `Event`). */ export interface ActionParams< Context extends object, Event, @@ -161,8 +101,7 @@ export type Action< Send = Event, > = (params: ActionParams) => void -/** One branch of a oneOf: an optional guard + the actions to run if it wins - * (a single action or a list — the runtime normalizes when it runs them). */ +/** One branch of a `oneOf`: optional guard + actions to run if it wins. */ export interface OneOfBranch< Context extends object, Event, @@ -188,11 +127,7 @@ export interface OneOf< readonly branches: Array> } -/** - * An action arg in an `actions` list: an inline action, a registered name - * (resolved against implementations.actions), or a `oneOf(...)` conditional - * branch. Missing name → throw in dev, warn in prod. A list runs in order. - */ +/** An action arg: inline fn, registered name, or `oneOf(...)` conditional. */ export type ActionArg< Context extends object, Event, @@ -205,9 +140,7 @@ export type ActionArg< | ActionName | OneOf -/** What an `actions` / `entry` / `exit` slot accepts: a single action or a list. - * The runtime normalizes a single value to a one-element list, so `actions: act(...)` - * and `actions: [act(...), 'log']` are both valid. */ +/** A single action arg or a list. The runtime normalizes single values to a one-element list. */ export type Actions< Context extends object, Event, @@ -219,10 +152,6 @@ export type Actions< | ActionArg | Array> -// ----------------------------------------------------------------------------- -// Effects -// ----------------------------------------------------------------------------- - /** An inline effect: runs on enter, optionally returns a cleanup run on exit. */ export type Effect< Context extends object, @@ -231,8 +160,7 @@ export type Effect< Send = Event, > = (params: ActionParams) => void | (() => void) -/** An effect arg: an inline effect or a registered name (resolved against - * implementations.effects). Missing name → throw in dev, warn in prod. */ +/** An inline effect or a registered name. */ export type EffectArg< Context extends object, Event, @@ -241,14 +169,7 @@ export type EffectArg< EffectName extends string = AnyString, > = Effect | EffectName -// ----------------------------------------------------------------------------- -// Computed -// ----------------------------------------------------------------------------- - -/** A single computed definition: derives a value from context, the current - * `state`, and other computeds. Reading `state` makes the lifecycle a tracked - * dependency, so a transition re-evaluates the computed (same memoization as a - * context-key read). */ +/** A single computed definition. Reading `state` makes the lifecycle a tracked dependency. */ export type ComputedDef< State extends string, Context, @@ -261,20 +182,11 @@ export type ComputedDefs = { [K in keyof Computed]: ComputedDef } -// ----------------------------------------------------------------------------- -// Delays -// ----------------------------------------------------------------------------- - -/** A named delay: resolves to a number of ms, may read context/computed so a - * prop-driven delay is dynamic. Referenced by name in a state's `after`. */ +/** A named delay: resolves to ms. May read context/computed for dynamic delays. */ export type Delay> = ( params: GuardParams, ) => number -// ----------------------------------------------------------------------------- -// Config -// ----------------------------------------------------------------------------- - /** The named-implementation registries a config supplies. */ export interface Implementations> { /** Reusable named guards. Referenced by name in a transition `guard`. */ @@ -288,13 +200,8 @@ export interface Implementations`) — so an action under `on.set` reads - * `event.value` without a manual cast, and a key that isn't a real event type - * errors. `send` keeps the FULL `Event` union (passed as the trailing `Send` - * arg), because an action routinely dispatches OTHER events. Partial: a state - * handles only the events it cares about. + * The `on` map: each key is an `Event['type']`; its entry's `event` narrows to that variant. + * `send` keeps the full `Event` union so actions can dispatch any event. */ export type EventMap< State extends string, @@ -320,17 +227,13 @@ export interface TransitionConfig< Context extends object, Event extends { type: string }, Computed = Record, - // The registered-name unions. All default to `AnyString` (any name compiles — - // today's behavior); `setup.as().config(...).createMachine(...)` supplies the real - // registry keys so a `guard`/action/effect/delay name is checked + autocompleted. + // Name unions default to `AnyString`; `setup.as().config(...)` supplies the real keys. GuardName extends string = AnyString, ActionName extends string = AnyString, EffectName extends string = AnyString, DelayName extends string = AnyString, > { - // NoInfer: `initial` is checked against the State union, which is inferred - // solely from the `states` keys below (the single source of truth) — so a - // mistyped initial errors and autocompletes the declared states. + // NoInfer: checked against State (inferred from `states` keys), not contributing to it. initial: NoInfer context: Context states: Record< @@ -391,10 +294,6 @@ export type MachineConfig< Computed = Record, > = TransitionConfig -// ----------------------------------------------------------------------------- -// Subscription surface -// ----------------------------------------------------------------------------- - /** Compare two selected values; return true if equal (no fire). */ export type EqualityFn = (a: Value, b: Value) => boolean @@ -407,10 +306,7 @@ export interface Selection { subscribe: (listener: (value: Value) => void, equals?: EqualityFn) => () => void } -/** - * The `select` builder: callable for the function form, with typed named-scope - * methods. Each form returns a Selection. - */ +/** The `select` builder: callable for the function form, with typed named-scope methods. */ export interface Select { /** Function form: derived/composite selection over anything. */ (selector: () => Value): Selection @@ -422,16 +318,7 @@ export interface Select { state: () => Selection } -// ----------------------------------------------------------------------------- -// Machine service -// ----------------------------------------------------------------------------- - -/** - * A machine service — the live, running instance produced by `machine(config)`. - * Built stopped; `start()` boots its effects, `stop()` runs their cleanups. - * Reads (state/context/computed) are plain getters; transitions go through - * `send`; observe via `subscribe` (coarse) or `select` (value-deduped). - */ +/** A live machine service. Built stopped; `start()` boots effects, `stop()` runs cleanups. */ export interface Machine< State extends string, Context extends object, @@ -451,26 +338,16 @@ export interface Machine< /** Narrow to a value-deduped Selection. Callable for the function form * (select(fn)); typed named scopes (select.context/.computed/.state). */ select: Select - /** Boot the machine: start the current state's effects (the initial state's - * on a fresh machine) and the watchers. Idempotent; a re-start after stop - * re-boots whatever state the machine is in. */ + /** Start effects + watchers. Idempotent; re-start re-boots from whatever state the machine is in. */ start: () => void - /** Run all active effect/watcher cleanups and mark stopped. Consumer - * subscriptions (subscribe/select) are the consumer's to dispose. */ + /** Stop all active effects + watchers. Consumer subscriptions are the consumer's to dispose. */ stop: () => void - /** Register a listener fired on every `start()` (and immediately if already - * running). Returns an unregister. Lets an outer layer hang start-scoped work - * off the lifecycle — e.g. the connector wiring its reactions. */ + /** Register a listener fired on every `start()` (immediately if already running). */ onStart: (fn: () => void) => () => void - /** Register a listener fired on every `stop()`. Returns an unregister. - * The teardown counterpart to onStart. */ + /** Register a listener fired on every `stop()`. */ onStop: (fn: () => void) => () => void } -// ----------------------------------------------------------------------------- -// Connector -// ----------------------------------------------------------------------------- - /** What a component's connect() receives. Machine reads are live getters. */ export interface ConnectSnapshot< State extends string, @@ -487,16 +364,9 @@ export interface ConnectSnapshot< } /** - * A substrate-agnostic reaction: `[selector, callback]`. When the value - * `selector` derives from the machine changes, the connector calls - * `callback(value, props)`. This is how a component declares "machine-state - * change → consumer callback" ONCE (e.g. `onOpenChange`), fired identically on - * every target — the machine never reads props or fires callbacks itself. - * (Platform-specific reactions like a DOM Escape listener stay in the - * per-target effects.) - * - * Tuple shape mirrors a React `ComponentEffect` (`[fn, deps]`) so the two read - * the same — declare each as a named const, collect them in a list. + * A substrate-agnostic reaction: `[selector, callback]`. When the selected value changes, + * the connector calls `callback(value, props)` — the way a component declares "machine-state + * change → consumer callback" once, fired identically on every target. */ export type Reaction< State extends string, @@ -510,11 +380,7 @@ export type Reaction< callback: (value: Value, props: Props) => void, ] -/** - * A pure connect(): snapshot → view-facing api. It MAY carry a static - * `reactions` array — declarative state-change → prop-callback bindings the - * connector registers once (the mapping itself stays pure / side-effect free). - */ +/** A pure connect(): snapshot → view-facing api. May carry a static `reactions` array. */ export type Connect< State extends string, Context extends object, @@ -535,18 +401,12 @@ export interface Connector< Props, Computed = Record, > { - /** Memoized connect() output. Stable identity until state/context/computed/ - * props change — safe as a useSyncExternalStore getSnapshot. */ + /** Memoized connect() output. Stable identity while inputs are unchanged — safe as useSyncExternalStore getSnapshot. */ readonly snapshot: Api - /** Coarse: wake on any change (also fires when props change). */ subscribe: (listener: () => void) => () => void - /** Per-field selection forwarded from the machine. */ select: Select - /** Update consumer props (a reactive input) — recomputes snapshot + wakes. */ + /** Update consumer props — recomputes snapshot + wakes subscribers. */ setProps: (props: Props) => void - /** Detach the connector from the machine: drops its bus subscription and any - * lifecycle hooks. Call when discarding the connector independently of the - * machine. (When the machine is discarded too — the common case — both are - * collected together and `destroy()` is optional.) */ + /** Detach from the machine. Only needed when discarding the connector independently of the machine. */ destroy: () => void } diff --git a/packages/core/tests/guards.test.ts b/packages/core/tests/guards.test.ts index bb4e46d..a5cdde3 100644 --- a/packages/core/tests/guards.test.ts +++ b/packages/core/tests/guards.test.ts @@ -36,7 +36,7 @@ describe('inline guards', () => { idle: { on: { add: { - guard: ({ event }) => event.by > 0, // only positive additions + guard: ({ event }) => event.by > 0, actions: [ ({ context, setContext, event }) => setContext({ n: context.n + event.by }), ], @@ -129,11 +129,12 @@ describe('named guards', () => { }) describe('combinators — and / or / not', () => { - it('and(): true only when every sub-guard passes (names)', () => { + // shared machine: context.a and context.b, both guarded by named guards isA/isB + const makeAB = (a: boolean, b: boolean) => { let ran = false const m = machine<'idle', { a: boolean; b: boolean }, { type: 'go' }>({ initial: 'idle', - context: { a: true, b: true }, + context: { a, b }, states: { idle: { on: { go: { guard: and('isA', 'isB'), actions: [() => (ran = true)] } } }, }, @@ -141,27 +142,20 @@ describe('combinators — and / or / not', () => { guards: { isA: ({ context }) => context.a, isB: ({ context }) => context.b }, }, }) - m.send({ type: 'go' }) - expect(ran).toBe(true) - }) + return { m, ran: () => ran } + } - it('and(): blocks when one sub-guard fails', () => { - let ran = false - const m = machine<'idle', { a: boolean; b: boolean }, { type: 'go' }>({ - initial: 'idle', - context: { a: true, b: false }, - states: { - idle: { on: { go: { guard: and('isA', 'isB'), actions: [() => (ran = true)] } } }, - }, - implementations: { - guards: { isA: ({ context }) => context.a, isB: ({ context }) => context.b }, - }, - }) + it('and(): passes only when every sub-guard passes', () => { + const { m, ran } = makeAB(true, true) m.send({ type: 'go' }) - expect(ran).toBe(false) + expect(ran()).toBe(true) + + const { m: m2, ran: ran2 } = makeAB(true, false) + m2.send({ type: 'go' }) + expect(ran2()).toBe(false) }) - it('or(): true when any passes; not(): negates; mixed names + inline fns', () => { + it('or(): passes when any sub-guard passes; not(): negates; names and inline fns mix', () => { let ran = false const m = machine<'idle', { locked: boolean }, { type: 'go'; force?: boolean }>({ initial: 'idle', @@ -184,30 +178,6 @@ describe('combinators — and / or / not', () => { expect(ran).toBe(true) }) - it('combinators accept inline functions too (not just names)', () => { - let ran = false - const isPos = ({ context }: { context: { n: number } }) => context.n > 0 - const m = machine<'idle', { n: number }, { type: 'go' }>({ - initial: 'idle', - context: { n: 5 }, - states: { - idle: { - on: { - go: { - guard: and( - isPos, - not(({ context }) => context.n > 100), - ), - actions: [() => (ran = true)], - }, - }, - }, - }, - }) - m.send({ type: 'go' }) // n=5: >0 AND not(>100) → true - expect(ran).toBe(true) - }) - it('nests deeply: and(or(...), not(and(...)))', () => { let ran = false const m = machine<'idle', { x: number }, { type: 'go' }>({ @@ -231,7 +201,7 @@ describe('combinators — and / or / not', () => { }, }, }) - // x=2: or(isTwo,isThree)=true; and(isTwo,isOdd)=false; not(false)=true → true + // x=2: or(isTwo,isThree)=true; and(isTwo,isOdd)=false; not(false)=true → runs m.send({ type: 'go' }) expect(ran).toBe(true) }) diff --git a/packages/native/src/normalize.ts b/packages/native/src/normalize.ts index 7817fa5..5f5130b 100644 --- a/packages/native/src/normalize.ts +++ b/packages/native/src/normalize.ts @@ -1,39 +1,14 @@ /** - * Translate the machine layer's LOGICAL surface to React Native props. + * Translate the machine layer's logical surface to React Native props. * - * Logical handler → RN gesture/event prop - * Logical attr → RN accessibility prop - * - * Differences from the React DOM normalizer worth flagging: - * - * - `onPress` keeps the same name (RN's Pressable.onPress). - * - `onPointerDown`/`onPointerUp` map to RN's `onPressIn`/`onPressOut`. - * - There's NO hover. `onPointerMove`/`onPointerEnter`/`onPointerLeave`/ - * `onPointerCancel` are dropped silently — the consuming view is expected to - * use a long-press gesture for tooltip-like activation. Components that - * need hover-like activation on RN should rely on focus or long-press. - * - `onFocus` / `onBlur` map to RN's TextInput-style focus events but - * only fire for focusable components. - * - `onValueChange` maps to RN's shared value-change slot (`onValueChange` — - * Switch/Picker/Slider); `onContextMenu` maps to `onLongPress`; - * `onScroll`/`onScrollEnd` map to `onScroll`/`onMomentumScrollEnd` (scroll - * containers only). `onDoublePress` and `onWheel` have no RN analog → dropped. - * - `describedBy` becomes `accessibilityLabelledBy` on Android; iOS - * doesn't have a direct equivalent (best to merge the description - * into accessibilityHint manually in the view). - * - `expanded`, `selected`, `disabled`, `hidden`, plus `checked` and `busy` - * become entries in `accessibilityState` (the only slots RN actually has). - * - `valueMin`/`valueMax`/`valueNow`/`valueText` fold into the nested - * `accessibilityValue` object (`{ min, max, now, text }`). - * - `label` → `accessibilityLabel`; `live` → `accessibilityLiveRegion` - * (`'off'` becomes RN's `'none'`). - * - `controls`, `hasPopup`, `modal` are DOM-ARIA-only and are dropped — RN - * overlays/modals are their own components, not element attributes. So are the - * ARIA attrs with no RN slot (`pressed`, `current`, `invalid`, `required`, - * `readOnly`, `activeDescendant`, `errorMessage`, `owns`, `orientation`, - * `sort`, `autoComplete`, `multiline`, `multiSelectable`, `level`, `posInSet`, - * `setSize`, the grid `col*`/`row*` set, `atomic`). - * - `role` maps to RN's `accessibilityRole`. + * Notable differences from the DOM normalizer: + * - `onPress` keeps its name; `onPointerDown`/`onPointerUp` → `onPressIn`/`onPressOut`. + * - No hover — pointer move/enter/leave/cancel are dropped. + * - `onContextMenu` → `onLongPress`; `onDoublePress`/`onWheel` dropped (no RN analog). + * - `expanded`/`selected`/`disabled`/`hidden`/`checked`/`busy` fold into `accessibilityState`. + * - `valueMin`/`valueMax`/`valueNow`/`valueText` fold into `accessibilityValue`. + * - `live` → `accessibilityLiveRegion`; `'off'` → `'none'`. + * - `controls`/`hasPopup`/`modal` and most ARIA-only attrs are dropped. */ const HANDLER_MAP: Record = { @@ -42,16 +17,13 @@ const HANDLER_MAP: Record = { onPointerUp: 'onPressOut', onFocus: 'onFocus', onBlur: 'onBlur', - // value-change shares RN's onValueChange (Switch/Picker/Slider); context = - // long-press; scroll/scroll-end attach to scroll-container components. onValueChange: 'onValueChange', onContextMenu: 'onLongPress', onScroll: 'onScroll', onScrollEnd: 'onMomentumScrollEnd', } -// Handlers that have no RN analog. We strip them rather than crash. -// (`onWheel` — no wheel input; `onDoublePress` — RN has no built-in multi-tap.) +// No RN analog — stripped. const HANDLER_DROP = new Set([ 'onPointerEnter', 'onPointerLeave', @@ -69,15 +41,10 @@ const ATTR_MAP: Record = { role: 'accessibilityRole', id: 'nativeID', label: 'accessibilityLabel', - // NOTE: `live` → accessibilityLiveRegion needs a value transform ('off' → - // 'none'), so it's handled inline in normalize(), not through this map. + // `live` needs a value transform ('off' → 'none'), handled inline in normalize(). } -// Attrs with no clean RN analog — stripped rather than passed through as -// invalid props. `controls`/`hasPopup`/`modal` are DOM ARIA-only (RN menus and -// modals use their own overlay/Modal-component semantics); the rest are ARIA -// attrs RN has no accessibility slot for — components convey them another way -// (an error label, editable=false on TextInput, the native focus hierarchy, …). +// No clean RN analog — stripped. const ATTR_DROP = new Set([ 'controls', 'hasPopup', @@ -107,11 +74,10 @@ const ATTR_DROP = new Set([ 'atomic', ]) -// Attrs that fold into accessibilityState (RN's slots are exactly these). +// RN's accessibilityState slots. const A11Y_STATE_KEYS = new Set(['disabled', 'expanded', 'selected', 'hidden', 'checked', 'busy']) -// Attrs that fold into the nested accessibilityValue object — logical key → RN -// sub-key. RN's AccessibilityValue is `{ min, max, now, text }`. +// Logical key → RN's accessibilityValue sub-key (`{ min, max, now, text }`). const A11Y_VALUE_KEYS: Record = { valueMin: 'min', valueMax: 'max', @@ -119,14 +85,6 @@ const A11Y_VALUE_KEYS: Record = { valueText: 'text', } -// Like the DOM normalizer, some handlers can't just be renamed: RN delivers a -// different SHAPE than the agnostic payload the component reads. onValueChange -// gives a BARE value (Switch/Picker/Slider call `onValueChange(value)`); -// onScroll/onMomentumScrollEnd give `{ nativeEvent: { contentOffset, -// contentSize, layoutMeasurement } }`. We wrap those so the component still -// receives ChangePayload / ScrollPayload. (onPress/onPressIn/onPressOut/ -// onLongPress/onFocus/onBlur pass through unwrapped — PointerPayload's optional -// fields tolerate RN's gesture-responder event.) type RNScrollEvent = { nativeEvent?: { contentOffset?: { x?: number; y?: number } @@ -136,8 +94,8 @@ type RNScrollEvent = { } const PAYLOAD_ADAPTERS: Record unknown> = { - // RN hands the new value directly. - onValueChange: value => ({ value }), + onValueChange: value => ({ value }), // RN hands the bare value + onScroll: scrollPayload, onScrollEnd: scrollPayload, } @@ -172,8 +130,6 @@ export function normalize(logical: Bindings): Record { const handler = HANDLER_MAP[key] if (handler) { const adapt = PAYLOAD_ADAPTERS[key] - // Wrap when RN's argument shape differs from the agnostic payload; else - // the handler shape already matches, so pass it through. out[handler] = adapt ? (arg: unknown) => (value as (p: unknown) => void)(adapt(arg)) : value continue } @@ -192,16 +148,13 @@ export function normalize(logical: Bindings): Record { } if (key === 'focusable') { - // RN's `focusable` controls hardware-keyboard/D-pad focus; a focusable - // element must also be `accessible` to be reachable by the screen reader. out.focusable = !!value - if (value) out.accessible = true + if (value) out.accessible = true // focusable must also be accessible for screen readers continue } if (key === 'live') { - // RN's accessibilityLiveRegion uses 'none' where ARIA uses 'off'. - out.accessibilityLiveRegion = value === 'off' ? 'none' : value + out.accessibilityLiveRegion = value === 'off' ? 'none' : value // ARIA 'off' → RN 'none' continue } diff --git a/packages/opentui/src/merge-props.ts b/packages/opentui/src/merge-props.ts index d437a8c..022ffab 100644 --- a/packages/opentui/src/merge-props.ts +++ b/packages/opentui/src/merge-props.ts @@ -2,11 +2,8 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' type AnyProps = Record -// OpenTUI's `style` is a plain object (unlike RN, which also accepts an array), -// so overlapping styles merge into ONE object rather than wrapping into an array. -// Library wins on conflicting keys, matching the agnostic base's last-wins rule -// for plain attrs (the library's computed style is the authoritative one). There -// is no `className` in a terminal, so — like RN — we add no className branch. +// OpenTUI style is a plain object (not array-mergeable like RN), so overlapping styles spread +// into one object with library winning on conflicts. No className in a terminal. export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { const merged = baseMergeProps(consumer, library) if (!consumer) return merged diff --git a/packages/opentui/src/normalize.ts b/packages/opentui/src/normalize.ts index acc8791..0972aa9 100644 --- a/packages/opentui/src/normalize.ts +++ b/packages/opentui/src/normalize.ts @@ -1,77 +1,29 @@ /** - * Translate the machine layer's LOGICAL surface to OpenTUI (terminal) props. + * Translate the machine layer's logical surface to OpenTUI (terminal) props. * - * Logical handler → OpenTUI mouse/keyboard/value event prop - * Logical attr → OpenTUI prop, or dropped (the terminal has no ARIA tree) - * - * Pure object→object: this is the framework-agnostic OpenTUI translator (see - * index.ts). What's substrate-specific is OpenTUI's terminal I/O model, and it - * diverges from both DOM and RN. The handler/prop names below are the real ones - * from `@opentui/core`'s Renderable and `@opentui/react`'s element types: - * - * - There is NO accessibility tree. A terminal has no screen-reader surface, so - * the entire ARIA attribute vocabulary (`role`, `label`, `describedBy`, - * `checked`, `expanded`, `valueNow`, the live-region + grid attrs, …) has no - * slot. Those attrs are DROPPED rather than passed through as invalid props. - * The two exceptions that DO have a visual analog are handled inline: - * `hidden` → `visible` (inverted), and `disabled` is preserved as `disabled` - * so the consuming component can dim/skip the renderable itself. - * - * - The pointer model is the MOUSE, reported in terminal cells. OpenTUI has no - * synthetic "click" — a press is a button-down at a cell — so `onPress` maps to - * `onMouseDown`. `onPointerDown`/`onPointerUp` map to `onMouseDown`/`onMouseUp`; - * `onPointerMove` → `onMouseMove`; `onPointerEnter`/`onPointerLeave` → - * `onMouseOver`/`onMouseOut` (OpenTUI's hover-equivalent over a cell region). - * `onPointerCancel` has no terminal analog → dropped. - * - * - `onContextMenu` (right-click / secondary activation) and `onDoublePress` - * have no dedicated OpenTUI slot — the raw `onMouse` catch-all carries the - * button/click-count, but there's no per-gesture handler — so both are dropped. - * - * - `onWheel` maps to `onMouseScroll` (terminal scroll IS a mouse-wheel event — - * core's MouseButton has WHEEL_UP/WHEEL_DOWN, and the event carries a `scroll` - * `{ direction, delta }`). `onScroll`/`onScrollEnd` are DROPPED: OpenTUI's - * `` has no scroll-position callback prop — it exposes scroll STATE - * (`scrollTop`/`scrollLeft`/`scrollWidth`/`scrollHeight` getters on the ref) and - * handles the wheel internally, so there's nothing to bind a handler to. - * - * - `onValueChange` maps to OpenTUI's `onChange`. ``'s `onChange` hands a - * bare string value; `'s onChange fires `(index, option)`, not a single arg. (onMouseDown/Up/ -// Move/Over/Out and onKeyDown pass through unwrapped — PointerPayload/ -// KeyboardPayload's optional fields tolerate OpenTUI's MouseEvent / key event.) +// Adapters are variadic — `'s onChange hands a bare string; `, index from 's onChange fires `(index, option)`, so all args reach - // the adapter. out[handler] = adapt ? (...args: unknown[]) => (value as (p: unknown) => void)(adapt(...args)) : value @@ -187,18 +124,15 @@ export function normalize(logical: Bindings): Record { } if (key === 'hidden') { - // The terminal has no aria-hidden; the visual analog is not rendering it. - out.visible = !value + out.visible = !value // no aria-hidden in a terminal; visual analog is not rendering continue } if (key === 'focusable') { - // OpenTUI's own focus flag — a plain boolean, no tabIndex/accessible dance. out.focusable = !!value continue } - // `disabled` and any unknown attrs (e.g. data-state, style) pass through. out[key] = value } diff --git a/packages/react/src/normalize.ts b/packages/react/src/normalize.ts index 1433ebf..7f7433c 100644 --- a/packages/react/src/normalize.ts +++ b/packages/react/src/normalize.ts @@ -1,9 +1,4 @@ -/** - * Translate the machine layer's LOGICAL surface to React DOM props. - * - * Logical handler → DOM event prop - * Logical attr → DOM/ARIA attr - */ +// Translate the machine layer's logical surface to React DOM props. const HANDLER_MAP: Record = { onPress: 'onClick', @@ -17,9 +12,7 @@ const HANDLER_MAP: Record = { onBlur: 'onBlur', onKeyDown: 'onKeyDown', onKeyUp: 'onKeyUp', - // value-change + secondary/double activation + scroll/wheel. onValueChange/ - // onWheel/onScroll/onScrollEnd additionally have their argument translated - // from the raw DOM event into the agnostic payload (see PAYLOAD_ADAPTERS). + // onValueChange/onWheel/onScroll/onScrollEnd also have their argument translated (see PAYLOAD_ADAPTERS). onValueChange: 'onChange', onContextMenu: 'onContextMenu', onDoublePress: 'onDoubleClick', @@ -28,14 +21,6 @@ const HANDLER_MAP: Record = { onScrollEnd: 'onScrollEnd', } -// Some handlers can't just be renamed: the agnostic payload the component reads -// (`ChangePayload`/`WheelPayload`/`ScrollPayload`) is a different SHAPE from the -// raw React synthetic event. For those, normalize wraps the handler so the -// component receives the agnostic payload — built here from the DOM event — -// rather than the DOM event itself. (onPress/pointer/keyboard handlers already -// receive a shape that overlaps PointerPayload/KeyboardPayload, so they pass -// through unwrapped, exactly as onPress always has.) - // DOM WheelEvent.deltaMode (0/1/2) → the neutral WheelPayload unit. const WHEEL_UNIT = ['pixel', 'line', 'page'] as const @@ -53,7 +38,6 @@ type AnyEvent = { const PAYLOAD_ADAPTERS: Record unknown> = { onValueChange: e => { const t = e?.target - // checkbox/radio carry the boolean on `.checked`; everything else on `.value`. const value = t && (t.type === 'checkbox' || t.type === 'radio') ? t.checked : t?.value return { value, defaultPrevented: e?.defaultPrevented, preventDefault: e?.preventDefault } }, @@ -146,19 +130,13 @@ export function normalize(logical: Bindings): Record { const handler = HANDLER_MAP[key] if (handler) { const adapt = PAYLOAD_ADAPTERS[key] - // Wrap when the agnostic payload differs from the raw DOM event; else the - // handler shape already matches (PointerPayload/KeyboardPayload), pass it. out[handler] = adapt ? (e: AnyEvent) => (value as (p: unknown) => void)(adapt(e)) : value continue } const attr = ATTR_MAP[key] if (attr) { - if (key === 'focusable') { - out[attr] = value ? 0 : -1 - } else { - out[attr] = value - } + out[attr] = key === 'focusable' ? (value ? 0 : -1) : value continue } diff --git a/packages/react/src/use-machine.ts b/packages/react/src/use-machine.ts index 6bb1c76..85530f9 100644 --- a/packages/react/src/use-machine.ts +++ b/packages/react/src/use-machine.ts @@ -2,18 +2,13 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' /** - * One substrate-specific effect, declared as a plain setup/teardown function - * plus the prop names it depends on: + * A substrate-specific effect: a setup/teardown function plus the prop names it depends on. + * Deps are prop key names (typed, so typos compile-error); the bridge maps them to React dep values. * * const escape: ComponentEffect = [ - * (machine, props) => { ...addEventListener...; return () => ...remove... }, - * ['closeOnEscape', 'onEscapeKeyDown'], // re-run when these props change + * (machine, props) => { addEventListener(…); return () => removeEventListener(…) }, + * ['closeOnEscape'], * ] - * - * The author writes no React. The deps are prop NAMES (typed `(keyof Props)[]`, - * so typos are compile errors); the bridge turns them into a precise React dep - * array, so the effect re-subscribes only when one of those props actually - * changes — not every render, never stale. `machine` is always an implicit dep. */ export type ComponentEffect = [ effect: (machine: Machine, props: Props) => (() => void) | void, @@ -21,34 +16,15 @@ export type ComponentEffect = [ ] /** - * A component's full set of substrate effects — a list, since one component can - * have several independent effects with DIFFERENT deps (e.g. an Escape listener - * gated by `closeOnEscape` and a Tab trap gated by `focusTrap`). Each gets its - * own React effect so only the one whose dep changed re-subscribes. - * - * MUST be a stable module constant (e.g. `export const xEffects = [...]`): - * `useMachine` calls one `useEffect` per entry, so the list's length has to be - * identical across renders (React's rules-of-hooks). A static export guarantees - * that — never build this array conditionally or per-render. + * 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 one generic React bridge. Every component's generated api.ts calls this - * with the agnostic pieces — a config factory and the connect — plus the - * component's substrate effects and the resolved props: - * - * useMachine(tooltipMachineConfig, connectTooltip, tooltipEffects, props) - * - * It: builds the machine from props, wraps it in a connector, starts on mount / - * stops on unmount (the connector's reactions follow the machine's lifecycle - * automatically), keeps props fresh via setProps, runs the component's - * prop-dependent effects (Escape, RN BackHandler — one `useEffect` each, keyed on - * their named prop deps), and drives React via useSyncExternalStore over the - * connector's stable snapshot. Returns the connect() api + the running machine. - * - * The machine is built ONCE (from the first render's props); later prop changes - * flow through setProps — recreating would lose state. + * 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 + * useSyncExternalStore over the connector's snapshot. */ export function useMachine< State extends string, @@ -63,8 +39,6 @@ export function useMachine< effects: ComponentEffects>, Props>, props: Props, ): { api: Api; machine: ReturnType> } { - // Build machine + connector once. The first render's props seed context + - // initial state. const { service, connection } = useMemo( () => { const service = machine(createConfig(props)) @@ -75,37 +49,21 @@ export function useMachine< [], ) - // Keep consumer props fresh (controlled flags, callbacks) — but in a PASSIVE - // effect, never during render. setProps writes a signal the snapshot reads; - // doing it in the render body would notify useSyncExternalStore mid-render and - // loop ("cannot update a component while rendering"). The connector was seeded - // with the first render's props in useMemo, so the initial snapshot is correct; - // this only pushes subsequent changes. setProps value-dedups, so a consumer - // that rebuilds an equal props object each render doesn't churn. + // Props updates run in a passive effect — doing it during render notifies useSyncExternalStore + // mid-render and loops. The connector was seeded at useMemo time, so the initial snapshot is correct. useEffect(() => { connection.setProps(props) }) - // Lifecycle: boot on mount, tear down on unmount. The connector wired its - // reactions to the machine's start/stop, so start()/stop() is all the bridge - // needs — reactions follow automatically, StrictMode remount included. - // - // We deliberately do NOT call connection.destroy() here: the connector shares - // this hook's lifetime with the machine (both live in the useMemo above), so - // they're GC'd together — and destroy() is one-way, which would break the - // StrictMode mount→unmount→mount cycle (the memo survives the remount, so a - // destroyed connector would be reused detached). destroy() exists for callers - // that build a connector standalone, outside this shared-lifetime pattern. + // Lifecycle: start on mount, stop on unmount. Reactions follow the machine's lifecycle automatically. + // Not calling connection.destroy() — connector and machine share lifetime via useMemo; + // destroy() would break StrictMode remount (memo survives, destroyed connector would be reused). useEffect(() => { service.start() return () => service.stop() }, [service]) - // Component effects — the prop-dependent platform listeners (Escape, RN - // BackHandler) the machine can't own. One useEffect per entry, keyed on - // [machine, ...named prop values], so an effect re-subscribes only when one of - // its deps actually changes. Safe to loop hooks: `effects` is a stable module - // constant (see ComponentEffects), so the count never changes between renders. + // One useEffect per effect entry — safe to loop because `effects` is a stable module constant. for (const [fn, deps] of effects) { useEffect( () => fn(service, props), @@ -114,7 +72,6 @@ export function useMachine< ) } - // Drive re-renders off the connector's stable, memoized snapshot. useSyncExternalStore( connection.subscribe, () => connection.snapshot, diff --git a/packages/react/src/use-selector.ts b/packages/react/src/use-selector.ts index 1b09dc5..e6a9adf 100644 --- a/packages/react/src/use-selector.ts +++ b/packages/react/src/use-selector.ts @@ -2,40 +2,13 @@ import { useMemo, useRef, useSyncExternalStore } from 'react' import type { EqualityFn, Machine } from '@dunky.dev/state-machine' /** - * Fine-grained, selector-based subscription for leaf components. - * - * The selector reads from the machine directly (`m.context.x`, `m.matches(...)`) - * and the component re-renders only when the selected VALUE changes — not on - * every machine change. - * - * Mechanism (not field-level auto-tracking): the machine's `select` is a coarse - * bus. Every selection re-evaluates its selector on each machine notify and - * value-compares the result; the component is woken only when its selected - * value actually changed. So the WORK done per machine change is O(selectors on - * that machine) — each re-evaluates — but the React RE-RENDERS are O(selectors - * whose value changed). For a leaf list backed by ONE machine per item (the - * common shape), each item has a single selector on its own machine, so a - * change wakes only that item. The deduping is what makes thousands of leaves - * cheap; it is value-based, not dependency-graph based. + * Fine-grained subscription for leaf components — re-renders only when the selected VALUE changes. * * const open = useSelector(m, () => m.matches('open')) * const isHL = useSelector(m, () => m.context.highlightedValue === value) * - * Equality is `Object.is` by default; pass a custom `isEqual` for object - * selections. - * - * The selector and isEqual are kept in refs and read through a STABLE inner - * Selection, so a per-render-fresh `selector` (e.g. one closing over a `value` - * prop) always evaluates its latest form WITHOUT re-creating the Selection or - * re-subscribing every render. Only `m` changing rebuilds the subscription. - * - * getSnapshot returns a value cached in a ref, refreshed only when the selected - * value actually changes (Object.is, or the caller's `isEqual`). That stable - * identity is REQUIRED: useSyncExternalStore re-renders whenever successive - * getSnapshot results differ by Object.is, so an object selection that returned - * a fresh `{...}` each call would re-render forever. The cache makes object - * selections safe (and `isEqual` the way to dedup them); primitives are - * unaffected. + * The selector runs in a stable Selection (so a fresh closure per render doesn't re-subscribe). + * getSnapshot caches the last value so object selections don't cause infinite re-render loops. */ export function useSelector< State extends string, @@ -48,36 +21,16 @@ export function useSelector< selector: () => T, isEqual?: EqualityFn, ): T { - // Keep the LATEST selector in a ref so a leaf passing a fresh closure each - // render (e.g. one closing over a changing `value` prop) always evaluates its - // current form, WITHOUT rebuilding the Selection or re-subscribing. Only the - // selector needs this — `isEqual` is read once at subscribe time, not per - // change, so it can be closed over directly (one fewer ref + per-render write - // per leaf, which adds up across thousands of leaves on mount). + // Refs so a fresh closure per render doesn't rebuild the Selection or re-subscribe. const selectorRef = useRef(selector) selectorRef.current = selector - - // Keep the LATEST isEqual in a ref too: getSnapshot (below) consults it to - // decide whether a freshly-evaluated value is "the same" as the cached one. - // It can change identity per render (an inline arrow), so reading it through a - // ref keeps getSnapshot stable without re-subscribing. const isEqualRef = useRef(isEqual) isEqualRef.current = isEqual - // One Selection over a stable wrapper that reads the current selector. Built - // once per machine; re-evaluated on every machine notify and value-deduped - // (coarse bus + value compare, not field-level dependency tracking). const selectorMemo = useMemo(() => machine.select(() => selectorRef.current()), [machine]) - // Cache the last value getSnapshot returned. useSyncExternalStore compares - // successive getSnapshot results by Object.is and re-renders whenever they - // differ — so getSnapshot MUST stay referentially stable while the selected - // value is unchanged. A raw `selectorRef.current()` breaks that for object - // selections: a fresh `{...}` every call is never Object.is-equal to the last, - // so React would re-render forever. We hold the last value in a ref and only - // replace it when the newly-evaluated value actually changed — Object.is by - // default, or the caller's `isEqual` for object selections. Primitives are - // unaffected (Object.is on equal primitives is true, so the cache is a no-op). + // Cache the last returned value — getSnapshot must be referentially stable for equal values + // or useSyncExternalStore re-renders on every call (breaks object selections). const cache = useRef<{ value: T } | null>(null) const getSnapshot = (): T => { const next = selectorRef.current() @@ -88,11 +41,6 @@ export function useSelector< } return useSyncExternalStore( - // The Selection's value-dedup still gates the bus → React notification (so a - // change to an UNRELATED field doesn't even wake this leaf). getSnapshot's - // cache is the second line of defense: it keeps the returned identity stable - // so React itself doesn't re-render on an equal value. The two together give - // both "don't wake on unrelated changes" and "don't loop on object identity". onStoreChange => selectorMemo.subscribe(() => onStoreChange(), isEqual), getSnapshot, getSnapshot, diff --git a/packages/shared/bindings/src/index.ts b/packages/shared/bindings/src/index.ts index 9df5461..f68888a 100644 --- a/packages/shared/bindings/src/index.ts +++ b/packages/shared/bindings/src/index.ts @@ -1,21 +1,5 @@ -// ============================================================================= -// @dunky.dev/state-machine-bindings -// -// The neutral binding vocabulary a `connect()` speaks — substrate-agnostic event -// handlers (`onPress`, `onValueChange`) and attributes (`role`, `expanded`, -// `describedBy`). Zero runtime, zero dependencies: pure types. -// -// This is the contract between behavior and platform. A `connect()` produces -// these bindings without knowing whether it runs on web, React Native, canvas, -// or a headless test; each renderer's `normalize()` is the ONLY code that turns -// them into platform props (web `aria-expanded`/`onClick`, RN -// `accessibilityState`/`onPress`). Splitting the vocabulary into its own package -// lets a renderer depend on the contract WITHOUT pulling in the engine. -// ============================================================================= - -// ----------------------------------------------------------------------------- -// Event payloads -// ----------------------------------------------------------------------------- +// Substrate-agnostic binding vocabulary: event handlers and attributes a connect() emits. +// Each renderer's normalize() is the only code that turns these into platform props. export interface PointerPayload { /** True when an upstream handler called preventDefault / equivalent. */ @@ -92,10 +76,6 @@ export interface ScrollPayload { viewportHeight?: number } -// ----------------------------------------------------------------------------- -// Event bindings — handlers bound to user input -// ----------------------------------------------------------------------------- - export interface EventBindings { /** "user clicked / tapped / activated this thing." */ onPress?: (event?: PointerPayload) => void @@ -140,10 +120,6 @@ export interface EventBindings { onScrollEnd?: (event?: ScrollPayload) => void } -// ----------------------------------------------------------------------------- -// Attr bindings — attributes bound to values -// ----------------------------------------------------------------------------- - export interface AttrBindings { id?: string diff --git a/website/src/content/docs/api/compose.mdx b/website/src/content/docs/api/compose.mdx index c41416d..e7eb8e3 100644 --- a/website/src/content/docs/api/compose.mdx +++ b/website/src/content/docs/api/compose.mdx @@ -18,9 +18,8 @@ const submenu = machine({ const combobox = compose({ popup, submenu }) combobox.start() // starts every member -combobox.stop() // stops all + disposes sync and combine +combobox.stop() // stops all members and disposes sync/combine -// members stay independent: drive and read each directly popup.send({ type: 'focus' }) submenu.send({ type: 'open' }) ``` diff --git a/website/src/content/docs/api/effects.mdx b/website/src/content/docs/api/effects.mdx index 1573205..cfff7ca 100644 --- a/website/src/content/docs/api/effects.mdx +++ b/website/src/content/docs/api/effects.mdx @@ -37,7 +37,6 @@ The deciding question: does it touch the platform (DOM, native APIs) or need pro **Machine effect:** props-free and platform-free. Identical on every target. Lives in the config `effects` array: ```ts -// a store subscription: same on DOM, React Native, anywhere effects: [ ({ context, send }) => tooltipStore.subscribe(s => { @@ -52,8 +51,7 @@ effects: [ import { type ComponentEffect } from '@dunky.dev/state-machine-react' import type { DisclosureMachine, DisclosureProps } from './machine' -// Close on Escape, but only if the `closeOnEscape` prop is true. -// This can't live in the machine because it touches the DOM and reads a prop. +// can't live in the machine: touches the DOM and reads a prop const onEscapeKey: ComponentEffect = [ (machine, props) => { if (!props.closeOnEscape) return @@ -66,8 +64,7 @@ const onEscapeKey: ComponentEffect = [ ['closeOnEscape'], // re-run only when this prop changes ] -// Collect all effects for this component into one stable constant. -// useMachine runs one useEffect per entry, so the list must never change length. +// list length must be stable across renders (one hook per entry) export const disclosureEffects = [onEscapeKey] ``` diff --git a/website/src/content/docs/api/reactions.mdx b/website/src/content/docs/api/reactions.mdx index 2fe5c40..325578e 100644 --- a/website/src/content/docs/api/reactions.mdx +++ b/website/src/content/docs/api/reactions.mdx @@ -12,7 +12,7 @@ const m = machine({ /* ... */ }) -// fix the machine generics once; Value is inferred per reaction +// pin generics once; selector return type flows into each callback const reaction = makeReaction() const onOpenChange = reaction( @@ -20,7 +20,6 @@ const onOpenChange = reaction( (open, props) => props.onOpenChange?.({ open }), // callback: open is boolean ) -// reactions live on the `connect` function; the connector picks them up on build connect.reactions = [onOpenChange] const c = connector(m, connect, props) diff --git a/website/src/content/docs/api/states.mdx b/website/src/content/docs/api/states.mdx index 7940bbb..1d6262b 100644 --- a/website/src/content/docs/api/states.mdx +++ b/website/src/content/docs/api/states.mdx @@ -31,7 +31,7 @@ Put an event in the top-level `on` to handle it from any state. A per-state `on` machine({ initial: 'a', context: {}, - on: { reset: { target: 'a' } }, // works from any state + on: { reset: { target: 'a' } }, states: { a: {}, b: {}, diff --git a/website/src/content/docs/api/timers.mdx b/website/src/content/docs/api/timers.mdx index 355eca7..bf4c536 100644 --- a/website/src/content/docs/api/timers.mdx +++ b/website/src/content/docs/api/timers.mdx @@ -14,11 +14,11 @@ const tooltip = machine({ states: { closed: { on: { hover: { target: 'opening' } } }, opening: { after: { openDelay: { target: 'open' } } }, - open: { after: { 2000: { target: 'closed' } } }, // auto-dismiss after 2s + open: { after: { 2000: { target: 'closed' } } }, }, implementations: { delays: { - openDelay: ({ context }) => context.openMs, // dynamic: reads context + openDelay: ({ context }) => context.openMs, // named delay: can read context/computed }, }, }) diff --git a/website/src/content/docs/api/watch.mdx b/website/src/content/docs/api/watch.mdx index 96b2b0e..68e1a1c 100644 --- a/website/src/content/docs/api/watch.mdx +++ b/website/src/content/docs/api/watch.mdx @@ -12,7 +12,7 @@ const m = machine({ initial: 'idle', context: { query: '' }, watch: { - query: ['runSearch'], // whenever `query` changes → run runSearch + query: ['runSearch'], }, states: { idle: {} }, implementations: { diff --git a/website/src/content/docs/comparison.mdx b/website/src/content/docs/comparison.mdx index 2fb5037..ff4c668 100644 --- a/website/src/content/docs/comparison.mdx +++ b/website/src/content/docs/comparison.mdx @@ -66,12 +66,9 @@ const toggle = setup({ flip: assign({ open: ({ context }) => !context.open, }), - // the machine itself calls the host's callback; it lives in context notify: ({ context }) => context.onOpenChange?.(context.open), }, }).createMachine({ - // host data enters the machine here: both defaultOpen and the callback - // become part of its state context: ({ input }) => ({ open: input.defaultOpen, onOpenChange: input.onOpenChange, @@ -96,15 +93,13 @@ import { createMachine } from '@zag-js/core' const toggle = createMachine({ props({ props }) { - // host props (defaultOpen, onOpenChange) are declared on the machine… return { defaultOpen: false, ...props } }, context({ bindable, prop }) { - // …and pulled in here: one bindable cell per field, seeded from a prop return { open: bindable(() => ({ defaultValue: prop('defaultOpen'), - onChange: open => prop('onOpenChange')?.({ open }), // callback fires from inside + onChange: open => prop('onOpenChange')?.({ open }), })), } }, @@ -123,7 +118,6 @@ prop and the callback live on the _connector_, at the edge: ```ts import { machine, connector } from '@dunky.dev/state-machine' -// The machine. No props, no callbacks, no host. Just states and events. const toggle = machine({ initial: 'closed', context: {}, @@ -133,7 +127,7 @@ const toggle = machine({ }, }) -// connect maps a snapshot → view API. `onPress` is an abstract binding, not `onClick`. +// `onPress` is an abstract binding; normalize() maps it to onClick / onPress per target const connect = ({ state, send }) => ({ isOpen: state === 'open', triggerProps: { @@ -141,15 +135,9 @@ const connect = ({ state, send }) => ({ }, }) -// A reaction calls the host from the OUTSIDE when a -// derived value flips. It lives on `connect`, not on the machine. +// reaction: fires the prop-callback from outside the machine when the value changes connect.reactions = [ - [ - // selector - toggle => toggle.matches('open'), - // callback - (open, props) => props.onOpenChange?.({ open }), - ], + [toggle => toggle.matches('open'), (open, props) => props.onOpenChange?.({ open })], ] const toggleConnection = connector(toggle, connect, props) diff --git a/website/src/content/docs/libs/react.mdx b/website/src/content/docs/libs/react.mdx index 6e68ad7..9d1041e 100644 --- a/website/src/content/docs/libs/react.mdx +++ b/website/src/content/docs/libs/react.mdx @@ -59,8 +59,6 @@ type Api = { contentProps: object } -// createDialogConfig: (props) => machine config. Defines the states -// ('closed' | 'open'), the events, and seeds context from the first props. export const createDialogConfig = (props: DialogProps) => ({ initial: props.open ? 'open' : 'closed', context: { closeOnEscape: props.closeOnEscape ?? true }, @@ -70,9 +68,6 @@ export const createDialogConfig = (props: DialogProps) => ({ }, }) -// connectDialog: a pure connect() that turns a machine snapshot into the view API -// your JSX spreads. `isOpen`, `triggerProps`, `contentProps` come from here. -// The type args are . export const connectDialog: Connect = ({ state, send, @@ -85,9 +80,6 @@ export const connectDialog: Connect = ( contentProps: { role: 'dialog', modal: true }, }) -// dialogEffects: DOM listeners that can't live in the machine. Here, one that -// closes the dialog on Escape. `onEscapeKey` is a [setup/teardown, deps] tuple; -// see the ComponentEffect section below for its full body. export const dialogEffects = [onEscapeKey] ``` @@ -174,8 +166,6 @@ const onEscapeKey: Effect = [ ['closeOnEscape'], // re-run only when this prop changes ] -// Must be a static module constant; useMachine runs one hook per entry, -// so the list length must never change between renders. export const dialogEffects = [onEscapeKey] ``` diff --git a/website/src/snippets/pacman.code.ts b/website/src/snippets/pacman.code.ts index 359498c..def9c55 100644 --- a/website/src/snippets/pacman.code.ts +++ b/website/src/snippets/pacman.code.ts @@ -1,4 +1,4 @@ -// 🟡 pacman: eats until the ghost gets him +// 🟡 pacman const pacman = machine({ initial: 'eating', context: { x: 1, y: 1, dir: 'right', mouth: 'open' }, @@ -22,7 +22,7 @@ const pacman = machine({ }, }) -// 👻 ghost: chases on each tick, stops on a catch +// 👻 ghost const ghost = machine({ initial: 'roaming', context: { x: 11, y: 10, dir: 'up' }, @@ -43,7 +43,7 @@ const ghost = machine({ }, }) -// 🍒 board: dots, cherry, score +// 🍒 board const board = machine({ initial: 'playing', context: { dots, cherry, score: 0 }, @@ -64,7 +64,7 @@ const board = machine({ }, }) -// ⏱️ a clock machine self-drives via `after`: no external loop +// ⏱️ clock: self-drives via `after`, no external loop const clock = machine({ initial: 'running', states: { @@ -76,7 +76,7 @@ const clock = machine({ }, }) -// 🎲 compose the four; sync() fans each beat to all regions in order +// 🎲 compose: sync() fans each clock beat to all regions in order const game = compose({ clock, pacman, ghost, board }) game.sync(() => { const { x, y } = step(pacman.context) diff --git a/website/src/snippets/trading-panel.code.ts b/website/src/snippets/trading-panel.code.ts index c42c201..0716e97 100644 --- a/website/src/snippets/trading-panel.code.ts +++ b/website/src/snippets/trading-panel.code.ts @@ -21,7 +21,7 @@ const { createMachine } = setup.as().confi }), 350, ) - return () => clearInterval(id) // ← cleanup on state exit + return () => clearInterval(id) }, }, }) @@ -44,7 +44,7 @@ const pair = createMachine({ TICK: { actions: ['updatePrice'], }, - }, // ← fires in all states + }, watch: { price: [ $ => { diff --git a/website/src/snippets/whiteboard.code.ts b/website/src/snippets/whiteboard.code.ts index 36634f6..8d3e782 100644 --- a/website/src/snippets/whiteboard.code.ts +++ b/website/src/snippets/whiteboard.code.ts @@ -20,7 +20,7 @@ const { createMachine } = setup.as().config const cursor = createMachine({ computed: { - // eased position: rendered directly by subscribers + // interpolated position read directly by subscribers on each frame ex: $ => $.context.x0 + ($.context.x1 - $.context.x0) * easeInOut($.context.progress), ey: $ => $.context.y0 + ($.context.y1 - $.context.y0) * easeInOut($.context.progress), },