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
11 changes: 11 additions & 0 deletions .changeset/after-timers-join-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@dunky.dev/state-machine': patch
---

Internal cleanup of the core machine. `after` timers now dispatch through
the same run-to-completion queue as `send`, instead of running their own
copy of the flush cycle; the stale-timer check keeps only the entry
generation, which already covers "state exited" and "state re-entered".
`send` and `setContext` are plain bound fields (no pass-through hops), the
boot event is one shared frozen object, and `oneOf` picks its branch with
a loop instead of `find`. No behavior change for consumers.
12 changes: 12 additions & 0 deletions .changeset/core-hot-path-allocations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@dunky.dev/state-machine': patch
---

Fewer allocations on the send path. A single action or transition entry is
run directly instead of being wrapped in a one-item array per event; guard
params are built only when a guard is actually met; the action and computed
hosts hold the live context and computed objects instead of reading them
through getter functions; the connector builds its snapshot argument once
and compares props without allocating key arrays. Single-event throughput
is up about 7% and state churn about 10% on the benchmark suite. No
behavior change for consumers.
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,11 @@ TEST; if not, ship it!
Every change is held to these four, in this order — simplest thing that
works, written once, built only when needed, behaving as promised:

| Principle | Meaning |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **KISS** | Keep it simple. Prefer the plain solution over the clever one; complexity must earn its keep with a need the simple version can't meet. |
| **DRY** | Don't repeat yourself. A rule two places must agree on is written once and shared — duplication is where the copies drift apart. |
| **YAGNI** | You aren't gonna need it. Build for the requirement in front of you, not the one imagined; speculative machinery is deleted-on-sight, not kept just in case. |
| Principle | Meaning |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **KISS** | Keep it simple. Prefer the plain solution over the clever one; complexity must earn its keep with a need the simple version can't meet. |
| **DRY** | Don't repeat yourself. A rule two places must agree on is written once and shared — duplication is where the copies drift apart. |
| **YAGNI** | You aren't gonna need it. Build for the requirement in front of you, not the one imagined; speculative machinery is deleted-on-sight, not kept just in case. |

### Naming

Expand Down
2 changes: 1 addition & 1 deletion packages/core/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ method names it exposes.

### Run-to-completion

- Dispatching an event **enqueues** it; the queue is drained one item at a
- Dispatching an event **enqueues** it; the queue is flushed one item at a
time. An event dispatched from inside an action, effect, or watcher is
appended and processed after the current item finishes — never
interleaved. The state graph is never observed mid-transition.
Expand Down
25 changes: 14 additions & 11 deletions packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ export function isOneOf<Context extends object, Event, Computed>(
)
}

// `context` / `computed` are the live objects — their identity never changes, so no getter.
export interface ActionHost<Context extends object, Event, Computed> {
actions: Record<string, Action<Context, Event, Computed>> | undefined
guards: Record<string, Guard<Context, Event, Computed>> | undefined
context: () => Context
computed: () => Computed
context: Context
computed: Computed
setContext: (patch: Partial<Context>) => void
send: (event: Event) => void
}
Expand All @@ -63,11 +64,13 @@ export function runAction<Context extends object, Event, Computed>(
event: Event,
): void {
if (isOneOf(action)) {
const params = makeGuardParams(host.context(), event, host.computed(), host.guards)
const branch = action.branches.find(b =>
b.guard ? resolveGuard(b.guard, params, host.guards) : true,
)
if (branch) runActions(host, branch.actions, event)
const params = makeGuardParams(host.context, event, host.computed, host.guards)
for (const branch of action.branches) {
if (!branch.guard || resolveGuard(branch.guard, params, host.guards)) {
runActions(host, branch.actions, event)
return
}
}
return
}
const named = action as Exclude<typeof action, OneOf<Context, Event, Computed>>
Expand All @@ -79,11 +82,11 @@ export function runAction<Context extends object, Event, Computed>(
return
}
fn({
context: host.context(),
context: host.context,
setContext: host.setContext,
event,
send: host.send,
computed: host.computed(),
computed: host.computed,
})
}

Expand All @@ -93,6 +96,6 @@ export function runActions<Context extends object, Event, Computed>(
event: Event,
): void {
if (!actions) return
const list = Array.isArray(actions) ? actions : [actions]
for (const action of list) runAction(host, action, event)
if (!Array.isArray(actions)) return runAction(host, actions, event)
for (const action of actions) runAction(host, action, event)
}
17 changes: 8 additions & 9 deletions packages/core/src/computed.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ComputedDefs } from './types'

// `context` / `computed` are the live objects (stable identity); only state is read through a fn.
export interface ComputedHost<State extends string, Context, Computed> {
context: () => Context
computed: () => Computed
context: Context
computed: Computed
state: () => State
}

Expand All @@ -19,8 +20,8 @@ export function defineComputed<State extends string, Context extends object, Com
): void {
// Dep keys are runtime strings, so all dep reads are string-indexed — widen once here
// instead of casting at every read site. The proxy target is inert (traps never touch it).
const contextOf = host.context as () => Record<string, unknown>
const computedOf = host.computed as () => Record<string, unknown>
const context = host.context as Record<string, unknown>
const computed = host.computed as Record<string, unknown>
const proxyTarget: Record<string, unknown> = {}

for (const key in defs) {
Expand All @@ -44,7 +45,7 @@ export function defineComputed<State extends string, Context extends object, Com
let tracking = false
const trackedCtx = new Proxy(proxyTarget, {
get: (_t, p: string) => {
const value = contextOf()[p]
const value = context[p]
if (tracking && !ctxDeps.includes(p)) {
ctxDeps.push(p)
ctxVals.push(value)
Expand All @@ -55,7 +56,7 @@ export function defineComputed<State extends string, Context extends object, Com

const trackedComputed = new Proxy(proxyTarget, {
get: (_t, p: string) => {
const value = computedOf()[p]
const value = computed[p]
if (tracking && !computedDeps.includes(p)) {
computedDeps.push(p)
computedVals.push(value)
Expand All @@ -76,16 +77,14 @@ export function defineComputed<State extends string, Context extends object, Com

const stale = (): boolean => {
if (readState && stateSnapshot !== host.state()) return true
const ctx = contextOf()

let i = 0
while (i < ctxDeps.length) {
if (!Object.is(ctxVals[i], ctx[ctxDeps[i]!])) return true
if (!Object.is(ctxVals[i], context[ctxDeps[i]!])) return true
i++
}

// Reading a computed dep resolves ITS staleness first — transitive changes surface here.
const computed = computedOf()
i = 0
while (i < computedDeps.length) {
if (!Object.is(computedVals[i], computed[computedDeps[i]!])) return true
Expand Down
51 changes: 26 additions & 25 deletions packages/core/src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,25 @@ export function connector<

let cached: Api
let dirty = true
const rebuild = (): Api =>
connect({
get state() {
return service.state
},
get context() {
return service.context
},
get computed() {
return service.computed
},
get props() {
return props
},
send: service.send,
})
// Built once — the getters read live values, so every rebuild can reuse the same object.
const connectArg = {
get state() {
return service.state
},
get context() {
return service.context
},
get computed() {
return service.computed
},
get props() {
return props
},
send: service.send,
}
const snapshot = (): Api => {
if (dirty) {
cached = rebuild()
cached = connect(connectArg)
dirty = false
}
return cached
Expand Down Expand Up @@ -91,16 +91,17 @@ export function connector<
}
}

// Runs on every render (setProps) — two for..in passes with a key counter, no key arrays.
function shallowEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false
const ak = Object.keys(a as object)
const bk = Object.keys(b as object)
if (ak.length !== bk.length) return false
for (const k of ak) {
if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) {
return false
}
const left = a as Record<string, unknown>
const right = b as Record<string, unknown>
let extraKeys = 0
for (const k in left) {
if (!Object.is(left[k], right[k])) return false
extraKeys++
}
return true
for (const _ in right) extraKeys--
return extraKeys === 0
}
4 changes: 2 additions & 2 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export const MACHINE_INIT = 'machine.init' as const
export const isDev: boolean = process.env.NODE_ENV !== 'production'

/**
* Dev-only runaway guard for one queue drain. A single send legitimately chains
* Dev-only runaway guard for one queue flush. A single send legitimately chains
* a handful of queued events / deferred watcher runs; thousands means a
* feedback loop (e.g. a watcher whose action keeps changing the field it
* watches, or actions sending in a cycle). Far above any real chain, so a hit
* is always a bug.
*/
export const MAX_DRAIN = 10_000
export const MAX_FLUSH = 10_000
Loading
Loading