Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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