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

`effect-machine/no-conditional-none-branch` now checks only event handlers in `on`. `Machine.can` queries events, so resolvers for invocation outcomes such as `onSnapshot` and `onDone`, and for eventless `always` transitions, may select a `none` branch conditionally:

```ts
onSnapshot: {
branches: "progress",
resolve: ({ snapshot, select }) => snapshot.ready ? select.ready() : select.waiting()
}
```

Remove any suppression comments added for these resolvers.
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,6 @@ export const invokeOutcomesMachine = Machine.make({
resolve: ({ select, snapshot }) =>
snapshot.state === "ready"
? select.ready({ data: new Completed({ source: "process", result: "ready" }), decoded: true })
// oxlint-disable-next-line effect-machine/no-conditional-none-branch -- `Machine.can` does not query snapshot outcomes; the titled branch documents the wait.
: select.waiting()
}
}
Expand Down
27 changes: 15 additions & 12 deletions packages/oxlint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ Submitted: {
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.
the same module's `make` call. An unconditional `none` selection, a resolver
with `declinable: true`, and resolvers for invocation outcomes and eventless
transitions, which `Machine.can` does not query, remain valid.

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

Expand Down Expand Up @@ -144,18 +145,20 @@ processes also require unique addresses while active.

```ts
// Incorrect: both declarations explicitly use the same ID.
invoke: ;
;[
{ src: "loadAccount", id: "load", ...accountOutcomes },
{ src: "loadTimeout", id: "load", ...timeoutOutcomes }
]
const conflicting = {
invoke: [
{ src: "loadAccount", id: "load", ...accountOutcomes },
{ src: "loadTimeout", id: "load", ...timeoutOutcomes }
]
}

// Correct: distinct registered source names supply distinct default IDs.
invoke: ;
;[
{ src: "loadAccount", ...accountOutcomes },
{ src: "loadTimeout", ...timeoutOutcomes }
]
const distinct = {
invoke: [
{ src: "loadAccount", ...accountOutcomes },
{ src: "loadTimeout", ...timeoutOutcomes }
]
}
```

The rule resolves static literals, local bindings, `Machine.childAddress`, and
Expand Down
3 changes: 2 additions & 1 deletion packages/oxlint-plugin/src/internal/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ const isStateConfig = (
return isStateConfig(stateCollection.parent.parent, bindings)
}

const isEventHandlerProperty = (
/** Whether a property declares one event handler in a state's `on` record. */
export const isEventHandlerProperty = (
node: ESTree.Node,
bindings: MachineBindings
): boolean => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
recordMachineDefinition,
recordMachineImport
} from "../imports.js"
import { isPlanningCallback, type PlanningFunction } from "../planning.js"
import { isEventHandlerProperty, isPlanningCallback, type PlanningFunction } from "../planning.js"

const propertyKey = (node: ESTree.Node): string | undefined => {
if (node.type !== "Property" || node.computed) return undefined
Expand Down Expand Up @@ -238,7 +238,7 @@ export const noConditionalNoneBranch: Rule = {
if (declinable !== undefined && (isTrueLiteral(declinable.value) || declinable.value.type !== "Literal")) {
return
}
if (!isPlanningCallback(callback, bindings)) return
if (!isEventHandlerProperty(transition.parent, bindings) || !isPlanningCallback(callback, bindings)) return

const handle = enclosingHandleCall(transition, bindings)
const configuration = handle === undefined ? undefined : makeConfiguration(handle, bindings)
Expand Down
5 changes: 5 additions & 0 deletions packages/oxlint-plugin/test/noConditionalNoneBranch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ tester.run("no-conditional-none-branch", rule, {
submit,
`{ branches: "submit", resolve: ({ state, select }) => state.ok ? select.review() : ((select) => select.incomplete())(other) }`
),
// Invocation outcomes and eventless transitions are not events that `Machine.can` queries.
`import { Machine } from "@typeonce/effect-machine"
Machine.make({ branches: { snapshot: { ready: { target: "Ready" }, waiting: { none: true } } } }).handle({ states: { Watching: { invoke: { src: "worker", onSnapshot: { branches: "snapshot", resolve: ({ snapshot, select }) => snapshot.ready ? select.ready() : select.waiting() } } } } })`,
`import { Machine } from "@typeonce/effect-machine"
Machine.make({ branches: { ${submit} } }).handle({ states: { Editing: { always: { branches: "submit", resolve: ({ state, select }) => state.ok ? select.review() : select.incomplete() } } } })`,
// Without a Machine import nothing is a machine definition.
`const Machine = { make: (_: unknown) => ({ handle: (_: unknown) => undefined }) }
Machine.make({ branches: { ${submit} } }).handle({ states: { Editing: { on: { Submitted: { branches: "submit", resolve: ({ state, select }) => state.ok ? select.review() : select.incomplete() } } } } })`,
Expand Down
Loading