From cb011ffc7fead28fc705f8b811eaf1bfb7f467f3 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 2 Nov 2026 07:56:12 +0900 Subject: [PATCH] Check no-conditional-none-branch only on event handlers Machine.can queries events, not invocation outcomes or eventless transitions, so no-conditional-none-branch now checks only resolvers of handlers in a state's `on` record. This removes the false positive on the devtools invoke-outcomes example, whose onSnapshot resolver selects a titled `none` branch while the worker starts, and its suppression comment. Also restore the no-conflicting-invocation-identity README example that the formatter had collapsed into `invoke: ;`. Co-Authored-By: Claude Opus 5.5 --- .../oxlint-conditional-none-event-handlers.md | 14 ++++++++++ .../browser/invoke-outcomes-example.ts | 1 - packages/oxlint-plugin/README.md | 27 ++++++++++--------- .../oxlint-plugin/src/internal/planning.ts | 3 ++- .../internal/rules/noConditionalNoneBranch.ts | 4 +-- .../test/noConditionalNoneBranch.test.ts | 5 ++++ 6 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 .changeset/oxlint-conditional-none-event-handlers.md diff --git a/.changeset/oxlint-conditional-none-event-handlers.md b/.changeset/oxlint-conditional-none-event-handlers.md new file mode 100644 index 00000000..bb18bcef --- /dev/null +++ b/.changeset/oxlint-conditional-none-event-handlers.md @@ -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. diff --git a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts index 7a8a13c0..da3869f2 100644 --- a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts +++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts @@ -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() } } diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index a3dde717..9d5ef1f8 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -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.()`, 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` @@ -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 diff --git a/packages/oxlint-plugin/src/internal/planning.ts b/packages/oxlint-plugin/src/internal/planning.ts index 864929ce..64ece393 100644 --- a/packages/oxlint-plugin/src/internal/planning.ts +++ b/packages/oxlint-plugin/src/internal/planning.ts @@ -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 => { diff --git a/packages/oxlint-plugin/src/internal/rules/noConditionalNoneBranch.ts b/packages/oxlint-plugin/src/internal/rules/noConditionalNoneBranch.ts index 21a2d72f..0dea8030 100644 --- a/packages/oxlint-plugin/src/internal/rules/noConditionalNoneBranch.ts +++ b/packages/oxlint-plugin/src/internal/rules/noConditionalNoneBranch.ts @@ -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 @@ -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) diff --git a/packages/oxlint-plugin/test/noConditionalNoneBranch.test.ts b/packages/oxlint-plugin/test/noConditionalNoneBranch.test.ts index 1e9ac453..6ed91db6 100644 --- a/packages/oxlint-plugin/test/noConditionalNoneBranch.test.ts +++ b/packages/oxlint-plugin/test/noConditionalNoneBranch.test.ts @@ -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() } } } } })`,