Skip to content
Closed
4 changes: 2 additions & 2 deletions .lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -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"]
}
85 changes: 8 additions & 77 deletions packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,82 +9,35 @@ export type Patch<Context extends object, Event, Computed = Record<string, never

/**
* `act(...patches)` — write-sugar for the most common action: setting context.
* It drops the `$ => $.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<Context, …>` 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<Context, Event, Computed>(...)` 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<Context, Event, Computed>(...)`.
*/
export function act<Context extends object, Event, Computed = Record<string, never>, Send = Event>(
...patches: Array<Patch<NoInfer<Context>, Event, Computed, Send>>
): Action<Context, Event, Computed, Send> {
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)
}
}
}

/**
* `oneOf(...branches)` — the conditional-action analog of a fallthrough
* transition: the first branch whose guard passes runs its actions; the rest are
* skipped. It lives inside an `actions` list (a transition's, or an `entry` /
* `exit`) where there's no transition array to fall through.
*
* Each branch is a plain `{ guard?, actions }` object — the same shape as a
* transition, minus `target` (a conditional WRITE never moves state; state
* choice is the surrounding transition's job). A guardless branch always matches,
* so put it last as the fallback. `actions` takes the usual vocabulary —
* `act(...)`, a raw fn, a named action, or a nested `oneOf` — as a single value or
* a list (the runtime normalizes when it runs them).
*
* actions: [
* act({ ariaPressed: true }),
* oneOf(
* { guard: $ => $.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<Context extends object, Event, Computed = Record<string, never>>(
...branches: Array<OneOfBranch<Context, Event, Computed>>
): OneOf<Context, Event, Computed> {
return { __oneOf: true, branches }
}

/**
* Recognize a `oneOf(...)` sentinel in an actions list. The runtime uses this to
* expand a oneOf into its winning branch. Lives next to `oneOf` (the only place
* that stamps `__oneOf`) so the marker has a single source of truth.
*
* Generic over the action-arg union so it narrows to the SAME Context/Event/
* Computed as the value passed in (rather than defaulting to `unknown`), keeping
* `branch.actions` correctly typed at the call site.
*/
export function isOneOf<Context extends object, Event, Computed>(
action: ActionArg<Context, Event, Computed>,
): action is OneOf<Context, Event, Computed> {
Expand All @@ -95,17 +48,6 @@ export function isOneOf<Context extends object, Event, Computed>(
)
}

// -----------------------------------------------------------------------------
// 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<Context extends object, Event, Computed> {
actions: Record<string, Action<Context, Event, Computed>> | undefined
guards: Record<string, Guard<Context, Event, Computed>> | undefined
Expand All @@ -115,12 +57,6 @@ export interface ActionHost<Context extends object, Event, Computed> {
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<Context extends object, Event, Computed>(
host: ActionHost<Context, Event, Computed>,
action: ActionArg<Context, Event, Computed>,
Expand All @@ -134,7 +70,6 @@ export function runAction<Context extends object, Event, Computed>(
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<typeof action, OneOf<Context, Event, Computed>>
const fn = typeof named === 'function' ? named : host.actions?.[named]
if (!fn) {
Expand All @@ -152,10 +87,6 @@ export function runAction<Context extends object, Event, Computed>(
})
}

/**
* Run an `actions` / `entry` / `exit` slot: a single action or a list, in order.
* Undefined slot is a no-op.
*/
export function runActions<Context extends object, Event, Computed>(
host: ActionHost<Context, Event, Computed>,
actions: Actions<Context, Event, Computed> | undefined,
Expand Down
11 changes: 2 additions & 9 deletions packages/core/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,8 @@ export interface Composition<Members extends Record<string, AnyMachine>> {
*/
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: <Value>(selector: () => Value) => Selection<Value>
}
Expand Down Expand Up @@ -63,9 +59,6 @@ export function compose<Members extends Record<string, AnyMachine>>(
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<Value> = Object.is) {
let prev = selector()
const onChange = () => {
Expand Down
42 changes: 7 additions & 35 deletions packages/core/src/computed.ts
Original file line number Diff line number Diff line change
@@ -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<State extends string, Context, Computed> {
/** 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<State extends string, Context extends object, Computed>(
target: Computed,
Expand All @@ -39,23 +22,17 @@ export function installComputed<State extends string, Context extends object, Co
const def = defs[k]
let computedOnce = false
let cachedValue: Computed[keyof Computed]
// recorded deps from the last run: context keys + computed keys read,
// plus whether the def read `state` (the lifecycle is also a dependency)
let ctxDeps: string[] = []
let computedDeps: string[] = []
let ctxSnapshot: Record<string, unknown> = {}
let computedSnapshot: Record<string, unknown> = {}
let readState = false
let stateSnapshot: State | undefined

// The two tracking proxies are built ONCE per computed (not per recompute).
// Their targets read the host's context / computed LIVE through the trap.
// Each `get` records the key into the CURRENT read-set, which the recompute
// swaps in before calling `def`.
// Tracking proxies built once per computed; each get records the key into the current read-set.
let ctxRead: Set<string> | null = null
let computedRead: Set<string> | 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<string, unknown>, {
get: (_t, p: string) => {
Expand All @@ -71,14 +48,12 @@ export function installComputed<State extends string, Context extends object, Co
}) as Computed

const stale = (): boolean => {
// 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<string, unknown>)[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<string, unknown>)[dk]))
return true
Expand All @@ -90,7 +65,6 @@ export function installComputed<State extends string, Context extends object, Co
enumerable: true,
get: () => {
if (computedOnce && !stale()) return cachedValue
// swap in fresh read-sets, recompute under the (reused) proxies
const cr = new Set<string>()
const compr = new Set<string>()
ctxRead = cr
Expand All @@ -101,8 +75,6 @@ export function installComputed<State extends string, Context extends object, Co
cachedValue = def({
context: trackedCtx,
computed: trackedComputed,
// a getter: reading `state` records it as a dependency (during
// tracking) so a later transition invalidates this computed
get state() {
if (tracking) readState = true
return host.state()
Expand Down
34 changes: 5 additions & 29 deletions packages/core/src/connector.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import type { Connect, Connector, Machine } from './types'

/**
* Wrap a machine + its pure connect() into a live snapshot. `props` is a
* reactive input: pass the initial value, push changes via setProps().
*
* connect() is a pure mapping (snapshot → view-facing api). The connector keeps
* that mapping live: it memoizes connect's output so the snapshot identity is
* stable until an input changes (no useSyncExternalStore infinite loop), reads
* machine state through live getters (no tearing), makes consumer `props` a
* reactive input (a props change recomputes the snapshot and wakes subscribers),
* and is PASSIVE — the bridge owns lifecycle.
*
* The snapshot recomputes on EITHER input: a machine change (via the machine's
* coarse subscribe) or a props change (via setProps). Both bump an internal
* revision; the memoized snapshot is rebuilt lazily on the next read.
* Wrap a machine + its pure connect() into a live, memoized snapshot.
* The snapshot recomputes lazily on any machine change or props change.
* The connector is PASSIVE — the bridge owns lifecycle.
*/
export function connector<
State extends string,
Expand All @@ -29,9 +19,6 @@ export function connector<
): Connector<State, Context, Api, Props, Computed> {
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 =>
Expand All @@ -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]) => {
Expand All @@ -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()
Expand Down
13 changes: 0 additions & 13 deletions packages/core/src/guards.ts
Original file line number Diff line number Diff line change
@@ -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<Context extends object, Event, Computed>(
guard: GuardArg<Context, Event, Computed>,
params: GuardParams<Context, Event, Computed>,
Expand All @@ -24,12 +17,6 @@ export function resolveGuard<Context extends object, Event, Computed>(
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 extends object, Event, Computed>(
context: Context,
event: Event,
Expand Down
Loading
Loading