@cssints/machine

中文文档
0.36.0发布于 2026/11/12MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2147

A minimal, dependency-free, type-safe state machine for UI protocols: effects as data, a stale effect cannot complete, debounce with a flush, an injectable clock and a harness that proves it without a browser

原始使用文档
文档目录9 节
  1. An animated disclosure
  2. The five properties it buys
  3. API
  4. Effects and handles
  5. The clock
  6. The harness
  7. The Solid bridge
  8. Limits
  9. Tests

@cssints/machine

A minimal, dependency-free, type-safe state machine for UI protocols: an animated disclosure, a menu that opens on hover and closes on a debounced leave, a toast that reverses mid-flight.

A signal holds state, not a protocol. The moment a component animates, the protocol appears: a closing and an opening in the same frame race; a cancelled animation still fires its completion; an interrupted close must reverse from where the box is; a burst of pointerenter/pointerleave must not produce five effects; a debounce must not swallow the last intent. None of those is expressible as a signal. This package is the runtime half: the smallest machine that can drive those protocols, and a harness that makes them provable without a browser.

No dependencies. The core is plain TypeScript (it runs through node's type stripping: no enums, no namespaces, no parameter properties); @cssints/machine/solid is the only file that imports anything, and solid-js is its optional peer.

An animated disclosure

import { createMachine, defineMachine } from "@cssints/machine";

type Events = { open: undefined; close: undefined; finish: { id: number } };

const disclosure = defineMachine<Events>()({
  start: "closed",
  initial: { progress: 0 },
  states: {
    closed: {
      on: { open: { target: "opening", effect: { type: "animate", to: "open" } } },
    },
    opening: {
      on: {
        finish: "open",
        close: { target: "closing", effect: { type: "animate", to: "closed" } },
      },
      after: { 400: "open" }, // an animation that never reports back still ends: no state is a trap
    },
    open: {
      on: {
        close: { target: "closing", effect: { type: "animate", to: "closed" } },
        tick: {
          target: "open",
          assign: (context, event) => ({
            progress: event.type === "tick" ? event.value : context.progress,
          }),
        },
      },
    },
    closing: {
      on: {
        finish: "closed",
        open: { target: "opening", effect: { type: "animate", to: "open" } },
      },
      after: { 400: "closed" },
    },
  },
});

const machine = createMachine(disclosure);

// The host runs the effects; the machine holds one handle per effect and cancels it when the state is left.
machine.onEffect((handle) => {
  const animation = box.animate(keyframes[handle.spec.to], { duration: 200 });
  animation.finished.then(() => machine.send({ type: "finish", id: handle.id }));
});

button.addEventListener("click", () => {
  machine.send(machine.getState().state === "closed" ? { type: "open" } : { type: "close" });
});

The five properties it buys

  1. A close interrupted by an open reverses and ends open. A transition out of a transient state is a transition like any other; the reverse's effect supersedes the close's effect.
  2. A cancelled animation's completion is ignored. A finish carries the handle id the host was given. Leaving the state cancels the handle, so a completion that arrives late is stale and dropped, not applied to the new state.
  3. Two events in one frame produce at most one effect per state. Each transition cancels the handles of the state it leaves; the host runs the last intent only.
  4. A debounced burst produces one effect carrying the last intent. send(event, { debounce }) (or a machine-level debounce map) coalesces by event type; flush() delivers the final intent, cancel() drops a superseded one.
  5. Nothing is ever stuck. Every state has an exit; after is the fallback for an effect that never reports back.

API

MemberWhat it does
defineMachine(config)A definition: { initial, states, start?, debounce? }. Events are a union of the on keys, the context is inferred from initial; an event no state accepts is a type error.
defineMachine<Payloads>()(config)The same with typed payloads: { close: undefined; finish: { id: number } }. A guard or an assign narrows on event.type.
createMachine(definition, options?)An instance. Options: clock, autoStart, onEffect, onCancel, onTransition.
machine.getState(){ state, context }; a new object per transition, the old one stays readable.
machine.subscribe(listener)Every transition and every debounce flush.
machine.subscribe(selector, listener)Only a change of selector's value (Object.is), the store bridge a host reads one slice with.
machine.send(event, { debounce? })Deliver now, or schedule a coalesced delivery.
machine.flush() / machine.cancel(type?)Deliver the pending intent now / drop it.
machine.effects()The live handles: issued, not completed, not cancelled.
machine.onEffect / onCancel / onTransitionThe host's side: run an effect, stop it, observe a delivery.
machine.start() / machine.destroy()Enter the start state (idempotent) / clear every timer, cancel every handle, drop every listener.
harness(definition)The machine on a fake clock: advance(ms), a queue (enqueue, deliver, deliverAll), race, interleave, flushEffects, the live cancelled() ids and the log.

A transition is a target string (open: "opening") or { target, guard?, effect?, assign? }; a state's on value is one transition or a list, the first whose guard passes. after maps ms to a transition; leaving the state clears the timer. assign is a Partial<context> or (context, event) => Partial<context>.

Effects and handles

Effects are data, not callbacks: { type: "animate", to: "open", duration: 200 }. The machine issues a handle ({ id, type, spec, state }) for every effect a state entry or a transition declares, and cancels it when the state is left or a transition supersedes it. A completion event is any event with a numeric id: a stale id is dropped (and logged as cancelled), a live one completes its handle without a cancellation. effects() is what the host should be running now.

An effect that takes longer than the state lives is cancelled; the host stops it (onCancel) and its late completion cannot move the machine.

The clock

createMachine(definition, { clock }) takes { now(), setTimeout(callback, ms), clearTimeout(handle) }; the default is systemClock. The same clock drives after, the debounce and a transition's timestamp, so a test drives time with advance(ms) and a host uses real time.

The harness

import { harness } from "@cssints/machine";

const h = harness(disclosure);
h.send({ type: "open" });
const animation = h.machine.effects().at(-1);

h.send({ type: "close" }); // the open's animation is cancelled, the close's is issued
h.send({ type: "finish", id: animation.id }); // stale: ignored
assert.equal(h.machine.getState().state, "closing");

h.advance(400); // the `after` fallback settles it
assert.equal(h.machine.getState().state, "closed");

h.log has one row per delivery (and one per effect it issued): { at, event, from, to, effect, context, cancelled }. h.race(...events) delivers a frame's events at one instant; h.interleave(...sequences) round-robins several orders. h.flushEffects() returns the handles the host should run now, not the ones already cancelled in the same frame.

The Solid bridge

@cssints/machine/solid (solid-js is a peer) exposes the machine as Solid's store layer:

import { createMachineStore } from "@cssints/machine/solid";

const store = createMachineStore(machine, {
  settled: (phase) => phase === "open" || phase === "closed",
  intent: (event) =>
    event.type === "close" ? "closed" : event.type === "open" ? "open" : undefined,
});
const view = store.projection(
  (draft, from) => {
    draft.visible = from.phase !== "closed";
  },
  { visible: false },
);
  • store.state is a createStore of { ...context, phase }, one setter call per transition, writing only the fields that changed: a reader of state.phase does not re-run when only progress changes.
  • store.optimistic is a second store over the same fields: it follows state, and an intent writes the phase it is heading to on top. The lead is dropped as soon as a transition reaches a settled phase, so the view reverts to the committed snapshot — an intent that is superseded before the finish rolls back instead of sticking.
  • store.projection(compute, seed) is a createProjection over state: only a changed field notifies, so a reader of view.visible does not re-run when progress changes. A projection reads the committed frame, not an optimistic override (Solid holds the override whole), so the visual phase is read off optimistic directly.
  • store.send(event) is the machine's send with the optimistic lead; sending on the machine directly skips it.

One setState per transition is the same atomicity the reactive runtime gets from its one flush (packages/reactive/src/runtime.ts, packages/apg-specs/specs/patterns/reactive.qnt): a reader never sees a phase from one transition with a progress from another.

Limits

  • One state at a time: no parallel states, no history states, no nested machines. A protocol with two independent regions is two machines and a host that sends to both.
  • Effects are data and the host runs them; the machine does not await a promise, own an animation or call a callback. A completion that never arrives is covered by after (or by a host that cancels).
  • A numeric id on an event is reserved for an effect handle: an event with a stale id is dropped.
  • The debounce coalesces by event type; two different sources of the same event type share one pending intent.
  • The store bridge's optimistic lead is taken only from a settled phase (the coordinator's settled); a new intent in the middle of a transition rolls the previous lead back rather than stacking a second one.
  • There is no Quint spec for this runtime yet; the harness's five properties are the oracle (test/machine.mjs).

Tests

cd packages/machine && bun run test   # node test/machine.mjs, then node test/store.mjs (Solid's client build)
cd packages/machine && bun run typecheck   # src and test/types (the @ts-expect-error cases)

The store test runs in plain node with --conditions=browser --conditions=development: Solid's store layer needs no DOM, but the node default is the SSR build, where a store's client-side transactions do not live. The package's test script carries the flags.

版本与发布记录

@cssints/machine 共发布 13 个版本,首次发布于 2026/11/02;过去 12 个月发布了 13 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.36.0latest2026/11/121 天前
0.35.12026/11/076 天前
0.35.02026/11/076 天前
0.34.02026/11/076 天前
0.33.02026/11/049 天前
0.32.02026/11/049 天前

关于 @cssints/machine 的常见问题

@cssints/machine 是什么?

A minimal, dependency-free, type-safe state machine for UI protocols: effects as data, a stale effect cannot complete, debounce with a flush, an injectable clock and a harness that proves it without a browser

如何安装 @cssints/machine?

在项目目录运行 npm install @cssints/machine,或使用 pnpm add / yarn add / bun add。

@cssints/machine 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cssints/machine 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cssints/machine 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@cssints/machine 可以商用吗?

@cssints/machine 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@cssints/machine 的下载量有多少?

根据 npm 官方下载统计,@cssints/machine 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,147 次,日均约 72 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cssints/machine 还在维护吗?

最新版本 0.36.0 发布于 2026/11/12(1 天前);至今共发布 13 个版本,其中过去 12 个月发布了 13 个;首次发布于 2026/11/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。