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
2 changes: 1 addition & 1 deletion .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Do not create a major changeset before 1.0. Backward compatibility is not a desi

## Synchronized package versions

`@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for the devtools dependency on core. A release affecting either package publishes both at the same version, so users can select compatible packages by matching their versions.
`@typeonce/effect-machine`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for the devtools dependency on core. A release affecting any package publishes all three at the same version, so users can select compatible packages by matching their versions.

## Writing changelog entries

Expand Down
9 changes: 9 additions & 0 deletions .changeset/calm-machines-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@typeonce/effect-machine": minor
"@typeonce/effect-machine-devtools": minor
"@typeonce/oxlint-plugin-effect-machine": minor
---

Add `@typeonce/oxlint-plugin-effect-machine` with recommended rules for redundant default resolvers, asynchronous planning callbacks, and one-use intermediate `Machine.make(...)` definitions.

All three Effect Machine packages now release at the same version.
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"fixed": [
[
"@typeonce/effect-machine",
"@typeonce/effect-machine-devtools"
"@typeonce/effect-machine-devtools",
"@typeonce/oxlint-plugin-effect-machine"
]
],
"linked": [],
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ When compatibility, convenience, concision, and semantic clarity conflict, prefe
## Experimental versioning

- The library is experimental and pre-1.0. Public additions and breaking API changes use a minor changeset; compatible fixes and implementation improvements use a patch changeset. Do not create major changesets before 1.0.
- `@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependency, and release configuration synchronized so users can install matching versions for compatibility.
- `@typeonce/effect-machine`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependency, and release configuration synchronized so users can install matching versions for compatibility.
- Backward compatibility is not currently a design goal. Change or remove an existing API whenever a clearer, safer, smaller long-term design replaces it.
- Do not add deprecated aliases, compatibility wrappers, or parallel APIs solely to preserve an inferior existing design unless the user explicitly requests them.
- Explain the resulting API and direct migration in changesets. Do not use changelog entries to credit an external library or narrate implementation history.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ This repository is the pnpm workspace for Effect Machine and its development too

- [`@typeonce/effect-machine`](./packages/effect-machine/README.md) contains the machine runtime, testing modules, and documentation.
- [`@typeonce/effect-machine-devtools`](./packages/devtools/README.md) contains the publishable local machine visualizer and CLI.
- [`@typeonce/oxlint-plugin-effect-machine`](./packages/oxlint-plugin/README.md) checks Effect Machine models for common structural mistakes.

Both packages use the same version. Install matching versions of core and devtools so their inspection protocol and machine model stay compatible.
All three packages use the same version. Install matching versions so the runtime, devtools, and lint rules stay aligned.

See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for repository development and validation commands.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"test:consumer": "node scripts/test-consumer.mjs",
"pack:check": "node scripts/pack-check.mjs",
"devtools:pack-check": "node scripts/devtools-pack-check.mjs",
"check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm devtools:pack-check",
"oxlint-plugin:pack-check": "node scripts/oxlint-plugin-pack-check.mjs",
"check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check",
"changeset": "changeset",
"version-packages": "changeset version && dprint fmt",
"release": "pnpm build && changeset publish"
Expand Down
199 changes: 199 additions & 0 deletions packages/effect-machine/docs/machine-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Effect Machine implementation review

Use this review after a machine and its UI adapter have been implemented. Look
for modeling mistakes, redundant API usage, and logic that escaped the machine.
Do not force a change when the current ownership is intentional.

Read the [Effect Machine agent guide](./agent-guide.md) for statechart modeling
and [Effect Atom and React patterns](./effect-atom-react.md) for integration
patterns.

## Review the responsibility boundaries

Use this split when deciding where code belongs:

| Layer | Owns |
| --- | --- |
| Machine | State, transitions, workflow decisions, effect lifetimes, and service execution |
| Atoms | Reactive selection, projections, and keyed machine lookup |
| Component | Rendering and translating user interaction into domain events |
| `RegistryProvider` | An optional Atom registry ownership boundary |

If a component coordinates a workflow, an atom performs business work, or a
provider has no ownership purpose, inspect that code more closely.

## Remove identity resolvers

A target that supports default construction does not need a resolver whose only
job is returning `target.from()`.

```ts
// Redundant
const handlers = {
Start: (to) =>
to.full.Running().resolve(({ target }) => target.from())
}

// Preferred
const handlers = {
Start: (to) => to.full.Running()
}
```

This applies to schema-less states and schemas whose constructor fields are all
optional or defaulted. The type checker rejects the shorter form when the
target needs data.

Keep `.resolve(...)` when it uses handler context, constructs state data,
updates a retained owner, chooses a branch, declines a transition, or enqueues
commands. For resolver-free reentry, use `.reenter()`:

```ts
const handlers = {
Refresh: (to) => to.local.Ready().reenter()
}
```

Review check: search for `.resolve(...)` callbacks that only return an empty
`target.from()` and remove the callback.

## Let `Atom.family` own keyed identity

Treat `useMemo` around an atom family lookup as a warning sign. `Atom.family`
already returns the same retained object for the same key, including when
separate components perform the lookup.

```tsx
// Redundant and local to one component
const scope = useMemo(() => processFamily(processId), [processId])

// The family owns identity
const scope = processFamily(processId)
```

If the component constructs the atoms or machine scope directly, move that
construction into a module-level family:

```ts
export const processFamily = Atom.family((processId: string) => {
const machine = machineAtoms.make(processMachine, { processId })

return {
stateAtom: AtomMachine.select(machine, "process"),
sendAtom: machine.send
}
})
```

Use a stable domain key. A new key means a different machine instance. Send an
event when a value should update the current workflow instead.

`useMemo` may still be useful for unrelated expensive calculations. It should
not establish atom or machine identity. For one instance owned only by a React
subtree, use a lazy `useState(makeScope)` initializer as described in the React
guide.

Review check: search for `useMemo` around atom creation, family lookup, or
`machineAtoms.make`. Replace component-local identity with `Atom.family`, or
with an intentional component-owned scope.

## Justify each `RegistryProvider`

Effect Atom hooks use a shared default registry when no provider is present.
Do not add `RegistryProvider` automatically.

Keep a provider when the subtree intentionally needs its own registry. Common
reasons include:

- the same atom or machine descriptors must represent independent instances in
separate subtrees;
- the subtree owns registry disposal;
- server rendering or hydration requires a request-local registry;
- the registry needs initial values, custom scheduling, or custom idle
settings.

Without one of those requirements, the default registry is enough:

```tsx
// A shared application instance can use the default registry.
export function App() {
return <ProcessScreen />
}
```

Remember that adding or nesting a provider changes identity. The same atom
descriptor stores separate state in each registry, so an unnecessary provider
can split a machine that consumers expected to share.

Review check: for every `RegistryProvider`, state which registry boundary it
creates and why. Remove it when there is no deliberate boundary.

## Keep workflow logic in the machine

The machine owns business flow. Components render selected state and send
domain events. Atoms adapt machine state for reactive consumers. Neither layer
should coordinate the workflow.

The following component owns too much:

```tsx
const submit = async () => {
send(OrderEvents.SubmitStarted())
const order = await api.submitOrder(form)
analytics.track("order submitted", { orderId: order.id })
send(OrderEvents.SubmitSucceeded({ order }))
}
```

Model `Submit` as the component-facing event. Let a machine state own the work
and its lifetime:

```ts
machine.handle({
Editing: {
on: {
Submit: (to) =>
to.full.Submitting().resolve(({ event, target }) =>
target.from({ order: event.order })
)
}
},
Submitting: {
invoke: (from) =>
from
.effect("submit-order", ({ state }) => submitOrder(state.order))
.onDone((to) =>
to.full.Complete().resolve(({ output, target }) =>
target.from({ order: output })
)
)
.onFailure((to) =>
to.full.Failed().resolve(({ error, target }) =>
target.from({ message: String(error) })
)
)
}
})
```

`submitOrder` can use Effect services for the API request and analytics. The
service implementation may live in its own module, but the machine decides
when it runs, which state owns it, what cancels it, and how success or failure
changes the workflow.

Apply the same test to browser APIs, storage, timers, analytics, navigation,
and other effects. If the result or lifetime affects machine behavior, execute
it through the machine. UI-only work such as focusing an element or measuring
layout can remain in the component when it does not participate in the domain
flow.

Review check: search components, hooks, and atom modules for service calls,
`Effect.run*`, promises, browser APIs, analytics, timers, and chains of `send`
calls. Move workflow coordination into states, transitions, and invoked work.

## Report findings

For each issue, cite the file and line, name the violated boundary, and show the
smallest correction. Distinguish a confirmed problem from a provider or
ownership choice that needs clarification. Do not rewrite correct code merely
to match an example in this guide.
21 changes: 21 additions & 0 deletions packages/oxlint-plugin/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Sandro Maglione

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2 changes: 2 additions & 0 deletions packages/oxlint-plugin/NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Effect Machine Oxlint Plugin is part of the Effect Machine project and is
distributed under the MIT License.
95 changes: 95 additions & 0 deletions packages/oxlint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Effect Machine Oxlint plugin

`@typeonce/oxlint-plugin-effect-machine` checks Effect Machine definitions for
redundant resolvers, asynchronous planning, and one-use intermediate machine
definitions.

The plugin uses Oxlint's JavaScript plugin interface. Custom JavaScript plugins
are currently alpha in Oxlint, so keep Oxlint and this package on versions that
have been tested together.

## Install

Install the plugin and the matching Effect Machine release:

```sh
pnpm add -D oxlint @typeonce/oxlint-plugin-effect-machine
pnpm add @typeonce/effect-machine
```

All Effect Machine packages use the same version.

## Configure

Load the plugin and its recommended rules from `oxlint.config.ts`:

```ts
import { recommended } from "@typeonce/oxlint-plugin-effect-machine/recommended"
import { defineConfig } from "oxlint"

export default defineConfig({
jsPlugins: ["@typeonce/oxlint-plugin-effect-machine"],
rules: recommended
})
```

JSON configurations can list the same rules directly:

```json
{
"jsPlugins": ["@typeonce/oxlint-plugin-effect-machine"],
"rules": {
"effect-machine/no-async-planning-callback": "error",
"effect-machine/no-redundant-resolve": "error",
"effect-machine/prefer-inline-handle": "error"
}
}
```

The rules are syntax-based. They recognize `Machine.make(...)`, direct chained
`.handle(...)` calls, and `.handle(...)` calls on definitions declared in the
same module. They do not resolve a machine definition imported from another
module.

## Rules

### `effect-machine/no-redundant-resolve`

Removes a resolver whose only result is empty default construction:

```ts
// Before
const handlers = {
Start: (to) => to.full.Running().resolve(({ target }) => target.from())
}

// After `oxlint --fix`
const handlers = {
Start: (to) => to.full.Running()
}
```

The fixer does not run when the resolver has options, comments, construction
input, or any other work.

### `effect-machine/no-async-planning-callback`

Rejects asynchronous transition, lifecycle, initial, choice, entry, exit, and
invocation-planning callbacks. A machine plans synchronously. Put asynchronous
work in state-owned `invoke` sources instead.

### `effect-machine/prefer-inline-handle`

Reports a private top-level `Machine.make(...)` definition when its only use is
one `.handle(...)` call:

```ts
// Before
const definition = Machine.make({/* ... */})
export const machine = definition.handle({/* ... */})

// After
export const machine = Machine.make({/* ... */}).handle({/* ... */})
```

Definitions that are exported or reused remain valid.
Loading