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
15 changes: 15 additions & 0 deletions .changeset/no-conditional-none-branch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@typeonce/oxlint-plugin-effect-machine": minor
---

Add the recommended `effect-machine/no-conditional-none-branch` rule.

A `{ none: true }` branch accepts its event, so `Machine.can` and `AtomMachine.can` return `true` when a resolver selects it. The rule reports a resolver that selects a `none` branch conditionally without `declinable: true`. When the event must be refused, for example to disable a control, decline it instead:

```ts
Submitted: {
branches: "submit",
declinable: true,
resolve: ({ state, select, decline }) => state.complete ? select.review() : decline()
}
```
19 changes: 16 additions & 3 deletions packages/effect-machine/docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,12 @@ const LoadMachine = Machine.make({
implemented machine type, so the runtime must provide them when it starts the
machine.

Model a per-instance capability, such as a router or a callback owned by a UI
component, as such a service. Do not put functions in state data or startup
input: snapshots must remain encodable. A React owner provides the capability
through its own runtime, as described in
[Per-owner capabilities](./effect-atom-react.md#per-owner-capabilities).

Do not start a promise inside a transition callback. A transition has no
lifetime in which to own that work. A state does.

Expand Down Expand Up @@ -408,6 +414,11 @@ Do not read the clock, generate randomness, call a service, or await work while
choosing a transition. Receive such values in an event or produce them through
state-owned work first.

A `{ none: true }` branch accepts its event without changing state, so
`Machine.can` reports `true` when a resolver selects it. When the event must be
refused, for example to disable a control, use `guard`, or declare
`declinable: true` and return `decline()` from the resolver.

When only an active state's value changes, use `update` to preserve its active
descendants and running work:

Expand Down Expand Up @@ -475,9 +486,11 @@ events, or runtime scheduling.

Use atom selectors to render state, invocation outcomes to express workflow
behavior, `ref.join` to await final output, and `ref.changes` for ongoing
observation. Reach for `Machine.waitFor(ref, predicate)` only when an external
Effect or test must wait for a specific current or subsequent snapshot, such as
a long-lived connection becoming ready. Compose `Effect.timeout` when needed.
observation. Emissions are non-replayed notifications; do not use them for work
the workflow depends on, such as navigation. Reach for
`Machine.waitFor(ref, predicate)` only when an external Effect or test must wait
for a specific current or subsequent snapshot, such as a long-lived connection
becoming ready. Compose `Effect.timeout` when needed.

Do not use `send` followed by `waitFor` as an acknowledgement protocol. The current
snapshot might already match, and a match does not identify which event caused
Expand Down
126 changes: 126 additions & 0 deletions packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,73 @@ machines, even when their inputs are equal.
`useMachineAtom(() => makeMachine(input))` remains available for custom owners.
It mounts the reference after commit without reading `result` or `snapshot`.

## Per-owner capabilities

A router, a callback owned by a component, or another per-instance capability
is a service, never a schema field or startup input. Declare the service, let a
state invoke the work that needs it, and provide the implementation from the
React owner:

```tsx
import { Machine } from "@typeonce/effect-machine"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { useMachineAtom } from "@typeonce/effect-machine-react"
import { Context, Effect, Layer } from "effect"
import { Atom } from "effect/reactivity"
import { AppLayer } from "../lib/app-layer"

export class Navigation extends Context.Service<Navigation, {
readonly push: (href: string) => Effect.Effect<void>
}>()("Navigation") {}

export const OnboardingMachine = Machine.make({
root: Machine.state({
states: { Editing: {}, Leaving: {}, Done: { type: "final" } }
}),
events: Machine.events({ Submit: {} }),
effects: {
navigateNext: Effect.flatMap(Navigation, (navigation) =>
navigation.push("/onboarding/next"))
}
}).handle({
initial: { target: "Editing" },
states: {
Editing: { on: { Submit: { target: "Leaving" } } },
Leaving: { invoke: { src: "navigateNext", onDone: { target: "Done" } } },
Done: {}
}
})

function OnboardingRoute() {
const router = useRouter()
const machine = useMachineAtom(() =>
AtomMachine
.bind(Atom.runtime(Layer.merge(
AppLayer,
Layer.succeed(Navigation, {
push: (href) => Effect.sync(() => router.push(href))
})
)))
.make(OnboardingMachine)
)

return <OnboardingForm machine={machine} />
}
```

The requirement stays on the machine type. Binding the machine to a runtime
without `Navigation`, such as the application-wide `MachineAtoms`, is a compile
error. Snapshots contain only schema data, so `Machine.encodeSnapshot` keeps
working, and tests substitute the capability with `Layer.mock(Navigation, ...)`.

Create the runtime inside the `useMachineAtom` factory so each owner builds it
once. `Atom.runtime` builds layers through the registry's memo map: the shared
`AppLayer` is built once per registry and reused, and each owner builds only its
own capability layer. Use `Layer.provideMerge(ownerLayer, AppLayer)` when the
owner layer itself needs application services. `createMachineContext` takes a
module-level factory, so distribute an owner-bound machine through props or a
React Context of your own.

## Render state-owned data

Render a typed path in the smallest component that needs it:
Expand Down Expand Up @@ -214,6 +281,47 @@ const submitAllowed = AtomMachine.can(submitEvent)
Changes to `draftAtom` recompute acceptance. The event atom contains the event
input itself rather than an `AsyncResult`.

`can` is `true` whenever the active configuration selects a transition. A
`{ none: true }` branch is a selected transition: it accepts the event without
changing state. A resolver that falls back to a `none` branch therefore keeps
the button enabled:

```ts
// Before: an incomplete form still accepts Submitted, so `can` is true.
// In make: branches: { submit: { review: { target: "Reviewing" }, incomplete: { none: true } } }
Submitted: {
branches: "submit",
resolve: ({ state, select }) =>
state.email.length > 0 ? select.review() : select.incomplete()
}
```

When the event must be refused, decline it. Use `guard` for an ordinary
transition, or `declinable: true` with `decline()` in a resolver:

```ts
// After: an incomplete form declines Submitted, so `can` is false.
Submitted: { target: "Reviewing", guard: ({ state }) => state.email.length > 0 }

// A resolver that needs commands or construction declines explicitly.
// In make: branches: { submit: { review: { target: "Reviewing" } } }
Submitted: {
branches: "submit",
declinable: true,
resolve: ({ state, select, decline }) =>
state.email.length > 0 ? select.review() : decline()
}
```

Keep `none` for events that are deliberately accepted and ignored. The
`effect-machine/no-conditional-none-branch` lint rule reports the conditional
fallback.

Query acceptance during render through `AtomMachine.can(event)(machine)`. Do
not call `Effect.runSync(Machine.can(...))` in a component: the atom shares the
running machine's snapshot, suspends during startup, and recomputes when the
snapshot changes.

## Whole-result and custom selections

Reading the full result is correct when a component renders the complete
Expand Down Expand Up @@ -357,6 +465,24 @@ type EditorState = AtomMachine.ChildMachineAtom.State<
>
```

## Keep workflow effects out of emissions

Emissions are ephemeral notifications for observers. They are not replayed, so
a subscriber that mounts late never sees an earlier emission. Do not drive work
the workflow depends on, such as navigation, from emissions. Invoke that work
from the state that requires it through a capability service, as in
[Per-owner capabilities](#per-owner-capabilities); the machine then decides when
it runs, what cancels it, and how failure changes the workflow.

An emission that only feeds UI feedback has two stream-atom details:

- `Atom.make(AtomMachine.emissions(machine))` publishes each emission as a
waiting `Success`, then publishes the last emission again, no longer waiting,
when the machine completes. Handlers must tolerate that repeated value.
- `useAtomSubscribe(atom, f)` subscribes after commit without reading the atom.
Pass `{ immediate: true }` so a lazy emissions atom starts; without it the
callback never runs. The callback then also receives the initial result.

## Registry and rendering semantics

A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same
Expand Down
22 changes: 18 additions & 4 deletions packages/effect-machine/docs/machine-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ patterns.
Run the recommended rules from
[`@typeonce/oxlint-plugin-effect-machine`](../../oxlint-plugin/README.md) before
the manual review. They catch provable duplicate invocation identities,
asynchronous work, browser access, nondeterminism, and redundant resolvers in
direct same-module machine definitions. Continue with this review for
cross-module ownership and architectural questions that syntax alone cannot
answer.
asynchronous work, browser access, nondeterminism, redundant resolvers, and
conditional `none` fallbacks in direct same-module machine definitions.
Continue with this review for cross-module ownership and architectural
questions that syntax alone cannot answer.

## Review the responsibility boundaries

Expand Down Expand Up @@ -43,6 +43,12 @@ constructors derived from those declarations. Avoid an extra branch group for
an ordinary transition. Use `reenter: true` when the source must restart.
An empty targetless resolver simplifies to `{ none: true }`.

A `{ none: true }` branch accepts its event, so `Machine.can` and
`AtomMachine.can` return `true` when a resolver selects it. Review check: for
every event that enables or disables a control, refuse it with `guard` or with
`declinable: true` and `decline()`. Keep `none` only for events that are
deliberately accepted and ignored.

## Choose React ownership or keyed family lookup

Use `useMachineAtom` when one React subtree owns the workflow, including a
Expand Down Expand Up @@ -187,6 +193,14 @@ 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.

Per-instance capabilities, such as a router or a component callback, are
services provided by the React owner, not schema fields or startup input. A
function in state data or input cannot be encoded by `Machine.encodeSnapshot`.
Review check: search schemas for `Schema.declare` guards that accept functions
and move each one to a service, as in
[Per-owner capabilities](./effect-atom-react.md#per-owner-capabilities). Do not
drive navigation or other workflow effects from emissions.

## Report findings

For each issue, cite the file and line, name the violated boundary, and show the
Expand Down
52 changes: 46 additions & 6 deletions packages/oxlint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Effect Machine Oxlint plugin

`@typeonce/oxlint-plugin-effect-machine` checks Effect Machine definitions for
invalid invocation identities, impure planning, redundant resolvers, and
one-use intermediate machine definitions.
invalid invocation identities, impure planning, redundant resolvers,
conditional `none` fallbacks that keep an event accepted, 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
Expand Down Expand Up @@ -41,6 +42,7 @@ JSON configurations can list the same rules directly:
"rules": {
"effect-machine/no-async-planning-callback": "error",
"effect-machine/no-browser-api-in-planning": "error",
"effect-machine/no-conditional-none-branch": "error",
"effect-machine/no-conflicting-invocation-identity": "error",
"effect-machine/no-nondeterministic-planning": "error",
"effect-machine/no-redundant-resolve": "error",
Expand Down Expand Up @@ -74,6 +76,44 @@ Ignore: { none: true }
The fixer preserves callbacks with comments or meaningful work. Ordinary
transitions with default construction use `{ target: "Ready" }`.

### `effect-machine/no-conditional-none-branch`

A `{ none: true }` branch accepts its event without changing state, so
`Machine.can` and `AtomMachine.can` report `true` when a resolver selects it.
The rule reports a resolver that selects a `none` branch conditionally without
`declinable: true`, because a control enabled by `can` would stay enabled:

```ts
// In make:
branches: {
submit: {
review: { target: "Reviewing" },
incomplete: { none: true }
}
}

// Incorrect: Submitted is still accepted while the form is incomplete.
Submitted: {
branches: "submit",
resolve: ({ state, select }) =>
state.complete ? select.review() : select.incomplete()
}

// Correct: the incomplete form declines Submitted.
Submitted: {
branches: "submit",
declinable: true,
resolve: ({ state, select, decline }) =>
state.complete ? select.review() : decline()
}
```

Remove the unused `none` branch from the group after the change, or use
`guard` on an ordinary transition. The rule recognizes `select.<key>()`,
destructured selectors, ternaries, and `if` returns against a group declared in
the same module's `make` call. An unconditional `none` selection and a resolver
with `declinable: true` remain valid.

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

Rejects asynchronous work in transition construction, guards, resolvers,
Expand Down Expand Up @@ -143,17 +183,17 @@ Receive facts through events or machine input, or obtain them in invoked work:
// In make:
branches: {
expiry: {
expired: { target: "Expired" },
current: { none: true }
expired: { target: "Expired" }
}
}

// In handle:
Check: {
branches: "expiry",
resolve: ({ event, select }) => event.now >= event.deadline
declinable: true,
resolve: ({ event, select, decline }) => event.now >= event.deadline
? select.expired.from()
: select.current()
: decline()
}
```

Expand Down
2 changes: 2 additions & 0 deletions packages/oxlint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Plugin } from "@oxlint/plugins"
import { noAsyncPlanningCallback } from "./internal/rules/noAsyncPlanningCallback.js"
import { noBrowserApiInPlanning } from "./internal/rules/noBrowserApiInPlanning.js"
import { noConditionalNoneBranch } from "./internal/rules/noConditionalNoneBranch.js"
import { noConflictingInvocationIdentity } from "./internal/rules/noConflictingInvocationIdentity.js"
import { noNondeterministicPlanning } from "./internal/rules/noNondeterministicPlanning.js"
import { noRedundantResolve } from "./internal/rules/noRedundantResolve.js"
Expand All @@ -13,6 +14,7 @@ const plugin = {
rules: {
"no-async-planning-callback": noAsyncPlanningCallback,
"no-browser-api-in-planning": noBrowserApiInPlanning,
"no-conditional-none-branch": noConditionalNoneBranch,
"no-conflicting-invocation-identity": noConflictingInvocationIdentity,
"no-nondeterministic-planning": noNondeterministicPlanning,
"no-redundant-resolve": noRedundantResolve,
Expand Down
Loading
Loading