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
12 changes: 12 additions & 0 deletions .changeset/reactive-event-acceptance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@typeonce/effect-machine": minor
---

Add `AtomMachine.can` for reactive event-acceptance queries with lifecycle-aware failures and stable derived atom identity.

Declare a projection once from a concrete event or an atom containing a changing event, then apply it to compatible machine bridges:

```ts
const submitAllowed = AtomMachine.can(AuthEvents.Submitted())
const canSubmitAtom = submitAllowed(authMachineAtom)
```
14 changes: 14 additions & 0 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,19 @@ The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
equality-aware derivations. Repeating one of these calls with the same bridge
and state path returns the same atom.

Use `AtomMachine.can` to project concrete event acceptance. Declare the
projection once and apply it to compatible bridges:

```ts
const submitAllowed = AtomMachine.can(Events.Submit({ draft }))
const canSubmitAtom = submitAllowed(machineAtom)
```

Pass an `Atom<EventInput>` instead when the event payload changes reactively.
Each projection returns the same derived atom for repeated applications to one
bridge. Startup and runtime failures remain typed, while done and stopped
machines return `false`.

Use `useMachineAtom` from `@typeonce/effect-machine-react` when one React
subtree owns the machine. It mounts the machine without subscribing the owner
to state. Pass the returned machine atom through props or Context, then call
Expand All @@ -800,6 +813,7 @@ preserving lazy registry startup and disposal:
```ts
const processAtoms = AtomMachine.bind(runtime).family(processMachine, {
atoms: {
canStart: AtomMachine.can(ProcessEvents.Start()),
details: AtomMachine.select("Processing"),
ready: AtomMachine.matches("Ready"),
send: (machine) => machine.send
Expand Down
53 changes: 25 additions & 28 deletions packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,42 +185,26 @@ its value.

## Query concrete event acceptance

`Machine.can` composes with `machine.snapshot` through a derived atom. Declare
the projection once for a module-level machine, or create it once alongside a
React-owned machine:
`AtomMachine.can` turns one concrete event input into a reusable machine
projection. Declare the projection once, then apply it to the React-owned
machine. Repeated applications to the same machine return the same atom:

```tsx
import { Effect, Equal } from "effect"
import { Atom } from "effect/unstable/reactivity"
import { Machine } from "@typeonce/effect-machine"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { useAtomSet, useAtomSuspense } from "@effect/atom-react"
import { useState } from "react"

const makeCanSubmitAtom = (machine: AuthMachineAtom) =>
Atom.make((get) =>
get.result(machine.snapshot).pipe(
Effect.flatMap((snapshot) => {
if (snapshot.status === "active") {
return Machine.can(AuthMachine, snapshot.state, { _tag: "Submitted" })
}
if (snapshot.status === "error") {
return Effect.failCause(snapshot.cause)
}
return Effect.succeed(false)
})
)
).pipe(Atom.withEquality(Equal.equals))
import { AuthEvents } from "../machines/auth-machine"

const submitAllowed = AtomMachine.can(AuthEvents.Submitted())

function SubmitButton() {
const machine = useAuthMachine()
const [canSubmitAtom] = useState(() => makeCanSubmitAtom(machine))
const canSubmit = useAtomSuspense(canSubmitAtom).value
const canSubmit = useAtomSuspense(submitAllowed(machine)).value
const send = useAtomSet(machine.send)

return (
<button
disabled={!canSubmit}
onClick={() => send({ _tag: "Submitted" })}
onClick={() => send(AuthEvents.Submitted())}
>
Continue
</button>
Expand All @@ -229,9 +213,22 @@ function SubmitButton() {
```

Startup still suspends, startup and runtime failures reach the error boundary,
and invalid event input remains a `MachineSchemaDecodeError`. Done and stopped
machines return `false`. When the event payload itself changes reactively, read
it from another atom inside the same derived atom.
and invalid event input for an active machine remains a
`MachineSchemaDecodeError`. Done and stopped machines return `false`.

When acceptance depends on a changing payload, project an event atom instead:

```ts
import { Atom } from "effect/unstable/reactivity"

const submitEvent = Atom.map(draftAtom, (draft) =>
AuthEvents.Submitted({ draft }))

const submitAllowed = AtomMachine.can(submitEvent)
```

Changes to `draftAtom` recompute acceptance. The event atom contains the event
input itself rather than an `AsyncResult`.

## Whole-result and custom selections

Expand Down
68 changes: 62 additions & 6 deletions packages/effect-machine/src/internal/machine/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ const preparedByMachineAtom = new WeakMap<
Atom.Atom<AsyncResult.AsyncResult<Machine.Prepared<any, any, any, any, any, any, any>, any>>
>()

const machineByMachineAtom = new WeakMap<object, Machine.Machine.Any>()

type WeakFamilyEntry<Value extends object> = {
readonly ref: WeakRef<Value>
}
Expand Down Expand Up @@ -422,7 +424,8 @@ const makeChildSelector = <StartError>(
}

const makeFromRefAtom = <State, Event, Error, Output, StartError, Emitted>(
ref: Atom.Atom<AsyncResult.AsyncResult<Machine.MachineRef<State, Event, Error, Output, Emitted>, StartError>>
ref: Atom.Atom<AsyncResult.AsyncResult<Machine.MachineRef<State, Event, Error, Output, Emitted>, StartError>>,
machine: Machine.Machine.Any
): MachineAtom<State, Event, Error, Output, StartError, Emitted> => {
const snapshot = Atom.readable((
get
Expand Down Expand Up @@ -498,7 +501,7 @@ const makeFromRefAtom = <State, Event, Error, Output, StartError, Emitted>(
const optionalRef = Atom.mapResult(ref, Option.some)
const child = makeChildSelector<StartError>(optionalRef as any)

return {
const result = {
ref,
snapshot,
state: Atom.mapResult(snapshot, (snapshot) => snapshot.state),
Expand All @@ -507,6 +510,8 @@ const makeFromRefAtom = <State, Event, Error, Output, StartError, Emitted>(
stop,
child
}
machineByMachineAtom.set(result, machine)
return result
}

type SnapshotNode<State> = State extends Machine.Machine.AtomicSnapshot<string, unknown> ?
Expand Down Expand Up @@ -716,6 +721,57 @@ export const matchesChild = <
Option.exists((snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path)))
).pipe(Atom.withEquality(Equal.equals)))

export const can = (event: unknown) => {
const byBridge = new WeakMap<object, Atom.Atom<AsyncResult.AsyncResult<boolean, any>>>()
return (self: MachineAtom<any, any, any, any, any, any>): Atom.Atom<AsyncResult.AsyncResult<boolean, any>> => {
const cached = byBridge.get(self)
if (cached !== undefined) return cached

const machine = machineByMachineAtom.get(self)
const query: (
state: Machine.Machine.Snapshot<any>,
event: unknown
) => Effect.Effect<boolean, Machine.MachineSchemaDecodeError> = machine === undefined
? () => Effect.die(new Error("AtomMachine.can requires a machine atom created by AtomMachine"))
: internalMachine.can(machine) as (
state: Machine.Machine.Snapshot<any>,
event: unknown
) => Effect.Effect<boolean, Machine.MachineSchemaDecodeError>

const result = Atom.readable((get): AsyncResult.AsyncResult<boolean, any> => {
const current = get(self.snapshot)
const previous = get.self<AsyncResult.AsyncResult<boolean, any>>()
if (AsyncResult.isInitial(current)) {
return AsyncResult.initial(current.waiting)
} else if (AsyncResult.isFailure(current)) {
return AsyncResult.failureWithPrevious(current.cause, {
previous,
waiting: current.waiting
})
} else if (current.value.status === "error") {
return AsyncResult.failureWithPrevious(current.value.cause, {
previous,
waiting: current.waiting
})
} else if (current.value.status !== "active") {
return AsyncResult.success(false, { waiting: current.waiting })
}

const input = Atom.isAtom(event) ? get(event) : event
const exit = Effect.runSyncExit(query(current.value.state, input))
return exit._tag === "Success"
? AsyncResult.success(exit.value, { waiting: current.waiting })
: AsyncResult.failureWithPrevious(exit.cause, {
previous,
waiting: current.waiting
})
}).pipe(Atom.withEquality(Equal.equals))

byBridge.set(self, result)
return result
}
}

type MachineResumeRequirementsOf<M extends Machine.Machine.Any> = MachineResumeRequirements<
Machine.Machine.Services<M>,
Machine.Machine.Event<M>,
Expand Down Expand Up @@ -796,7 +852,7 @@ export const make: {
} = ((machine: Machine.Machine.Any, ...args: ReadonlyArray<unknown>) => {
const prepared = Atom.make(() => internalMachine.prepare(machine as any, ...(args as [])))
const ref = Atom.make((get) => startPreparedMachineAtomEffect(get, prepared as any))
const result = makeFromRefAtom(ref as any)
const result = makeFromRefAtom(ref as any, machine)
preparedByMachineAtom.set(result, prepared as any)
return result
}) as any
Expand All @@ -815,7 +871,7 @@ export const resume: {
): ResumedMachineAtomOf<M, never>
} = ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) => {
const ref = Atom.make((get) => resumeMachineAtomEffect(get, machine, snapshot))
return makeFromRefAtom(ref as any)
return makeFromRefAtom(ref as any, machine)
}) as any

const makeWithRuntime = (
Expand All @@ -825,7 +881,7 @@ const makeWithRuntime = (
): MachineAtom<any, any, any, any, any, any> => {
const prepared = runtime.atom(() => internalMachine.prepare(machine as any, ...(args as [])))
const ref = runtime.atom((get) => startPreparedMachineAtomEffect(get, prepared as any))
const result = makeFromRefAtom(ref as any)
const result = makeFromRefAtom(ref as any, machine)
preparedByMachineAtom.set(result, prepared as any)
return result
}
Expand All @@ -836,7 +892,7 @@ const resumeWithRuntime = (
snapshot: Machine.Machine.Snapshot<any>
): MachineAtom<any, any, any, any, any, any> => {
const ref = runtime.atom((get) => resumeMachineAtomEffect(get, machine, snapshot))
return makeFromRefAtom(ref as any)
return makeFromRefAtom(ref as any, machine)
}

type FamilyBridge = MachineAtom<any, never, any, any, any, any> | ChildMachineAtom<any, any>
Expand Down
56 changes: 56 additions & 0 deletions packages/effect-machine/src/unstable/reactivity/AtomMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ type ChildSnapshot<Child extends Machine.ChildMachine.Any> = Machine.Machine.Sna

const InvalidSelectorPathTypeId = "~effect/reactivity/AtomMachine/InvalidSelectorPath"
const SelectorProjectionTypeId = "~effect/reactivity/AtomMachine/SelectorProjection"
const InvalidCanEventTypeId = "~effect/reactivity/AtomMachine/InvalidCanEvent"

type SelectorProjectionKind =
| "select"
Expand All @@ -374,6 +375,30 @@ interface SelectorProjection<Kind extends SelectorProjectionKind, Path extends s
}
}

type EnsureCanEvent<AcceptedEvent, Input> = [Input] extends [AcceptedEvent] ? unknown : {
readonly [InvalidCanEventTypeId]: {
readonly input: Input
readonly accepted: AcceptedEvent
}
}

interface CanProjection<Input> {
<
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
AcceptedEvent,
Error,
Output,
StartError,
Emitted
>(
self:
& MachineAtom<State, AcceptedEvent, Error, Output, StartError, Emitted>
& EnsureCanEvent<AcceptedEvent, Input>
): Atom.Atom<
AsyncResult.AsyncResult<boolean, StartError | Error | Machine.MachineSchemaDecodeError>
>
}

type EnsureSelectorPath<State, Path extends string> = [Path] extends [SnapshotIdentifier<State>] ? unknown : {
readonly [InvalidSelectorPathTypeId]: Path
}
Expand Down Expand Up @@ -695,6 +720,37 @@ export const matches: {
): Atom.Atom<AsyncResult.AsyncResult<boolean, StartError | Error>>
} = dual(2, internal.matches)

/**
* Reactively tests whether a concrete event would be accepted by a running
* machine.
*
* Declare the returned projection once, then apply it to compatible machine
* bridges. Repeated applications to the same bridge return the same atom. An
* event atom is read reactively when acceptance depends on a changing payload.
*
* Startup remains in the source `AsyncResult`. Active snapshots use
* {@link Machine.can}; done and stopped snapshots produce `false`, while
* runtime and schema failures remain in the typed failure channel.
*
* **Example**
*
* ```ts
* const submitAllowed = AtomMachine.can(AuthEvents.Submitted())
* const canSubmitAtom = submitAllowed(authMachineAtom)
*
* const submitEvent = Atom.map(draftAtom, (draft) =>
* AuthEvents.Submitted({ draft }))
* const reactiveSubmitAllowed = AtomMachine.can(submitEvent)
* ```
*
* @category combinators
* @since 0.31.0
*/
export const can: {
<Input>(event: Atom.Atom<Input>): CanProjection<Input>
<const Input>(event: Input): CanProjection<Input>
} = internal.can

/**
* Returns whether a state path is active in a directly owned child.
*
Expand Down
Loading