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
18 changes: 18 additions & 0 deletions .changeset/machine-state-children-owner-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@typeonce/effect-machine": minor
"@typeonce/effect-machine-react": minor
---

`MachineState` now renders child machines: pass a child bridge such as `machine.child(QuantityChild)` and the path and snapshot are typed by the child machine. `inactive` also renders while the state that owns the child is not active. `MachineStateProps` now takes the bridge type and the path, `MachineStateProps<A, Path>`.

Add `useMachineResult(atom)` for custom readers of result atoms such as `AtomMachine.select`, `matches`, or `can`. It serves the planned value during server rendering and hydration and keeps the last success while the machine starts, so hydrated markup is not replaced. Add `AtomMachine.isMachineAtom` to tell machine bridges from child bridges.

`createMachineContext` factories can declare a per-owner capability layer as a second parameter, and the Provider then requires a matching `layer` prop:

```tsx
const SignIn = createMachineContext((input: SignInInput, navigation: Layer.Layer<Navigation>) =>
AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer))).make(SignInMachine, input)
)

<SignIn.Provider input={input} layer={navigationLayer(router)} />
```
31 changes: 30 additions & 1 deletion packages/effect-machine-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,42 @@ renders `pending`; inactive paths render `inactive` (both null by default).
Failures propagate to the nearest error boundary. Keep the owner above any
Suspense boundary used by custom `useAtomSuspense` readers.

`MachineState` also accepts a child bridge. Its paths and snapshot are the
child's, and `inactive` also renders while the state that owns the child is not
active:

```tsx
<MachineState machine={machine.child(QuantityChild)} path="" inactive={null}>
{(snapshot) => <NumberField value={snapshot.value.quantity} />}
</MachineState>
```

Input is startup-only. Send an event to update a running workflow, or change the
Provider's React `key` to start a new one. Machines without input have a Provider
without an `input` prop.

Per-owner capabilities, such as a router-bound navigation service, are layers,
never input. Declare a second, `Layer`-typed factory parameter, and the Provider
requires a `layer` prop of that type. Like input, the layer is read once when
the Provider mounts:

```tsx
const SignIn = createMachineContext(
(input: SignInInput, navigation: Layer.Layer<Navigation>) =>
AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer)))
.make(SignInMachine, input)
)

<SignIn.Provider input={input} layer={navigationLayer(useRouter())}>
<SignInForm />
</SignIn.Provider>
```

`useMachineAtom(() => makeMachine(input))` remains available for custom owners.
Use `AtomMachine.select`, `selectSnapshot`, and `matches` for custom granular
subscriptions. Derive narrower projections with `Atom.family`, `Atom.mapResult`,
subscriptions, and read them with `useMachineResult(atom)`: like `MachineState`,
it serves the planned value during hydration and keeps the last success while
the machine starts, where `useAtomValue` would return `Initial` again. Derive narrower projections with `Atom.family`, `Atom.mapResult`,
and `Atom.withEquality` rather than hook options. Read `.result` for the whole logical snapshot or `.snapshot` for
runtime status; the bridge has no separate `.state` projection.

Expand Down
161 changes: 134 additions & 27 deletions packages/effect-machine-react/src/MachineAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import type { Machine } from "@typeonce/effect-machine"
import type { AtomMachine } from "@typeonce/effect-machine/reactivity"
import type { Layer } from "effect"
import type { AsyncResult, Atom } from "effect/reactivity"
import type * as React from "react"
import * as internal from "./internal/react.js"

Expand All @@ -21,26 +23,70 @@ import * as internal from "./internal/react.js"
*/
export const useMachineAtom: <A extends AtomMachine.MachineAtom.Any>(create: () => A) => A = internal.useMachineAtom

/** Props for a typed render of one active state path.
/**
* Reads a result atom from the current registry without suspending, keeping the
* last success while the atom is `Initial` again.
*
* **Details**
*
* Server rendering and hydration read the atom's server value, such as the
* planned startup projection of `AtomMachine.select`, `selectSnapshot`,
* `matches`, or `can`. When the live machine then starts, the atom is `Initial`
* until its first snapshot; this hook keeps returning the last `Success`
* instead, so the hydrated tree and its DOM nodes survive. Use it instead of
* `useAtomValue` for custom readers of machine atoms that render during
* hydration. Failures are returned, not thrown.
*
* @category hooks
* @since 0.43.0
*/
export const useMachineResult: <A, E>(
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
) => AsyncResult.AsyncResult<A, E> = internal.useMachineResult

/** Props for a typed render of one active state path of a machine or child machine.
* @category models
* @since 0.32.0
*/
export interface MachineStateProps<
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
Event,
Error,
Output,
StartError,
Emitted,
Path extends Machine.Snapshot.Path<State>
A extends MachineStateProps.Bridge,
Path extends Machine.Snapshot.Path<MachineStateProps.State<A>>
> {
readonly machine: AtomMachine.MachineAtom<State, Event, Error, Output, StartError, Emitted>
/** A machine bridge, or a child bridge from `machine.child(Descriptor)`. */
readonly machine: A
readonly path: Path
/** Rendered while the machine has no snapshot yet. Defaults to null. */
readonly pending?: React.ReactNode
/** Rendered while the path is not active. Defaults to null. */
/** Rendered while the path, or the state that owns the child, is not active. Defaults to null. */
readonly inactive?: React.ReactNode
readonly children: (state: Machine.Snapshot.At<State, NoInfer<Path>>) => React.ReactNode
readonly children: (state: Machine.Snapshot.At<MachineStateProps.State<A>, NoInfer<Path>>) => React.ReactNode
}

/**
* Type-level members associated with `MachineStateProps`.
*
* @category models
* @since 0.43.0
*/
export declare namespace MachineStateProps {
/**
* A bridge `MachineState` can render: a machine bridge or a child bridge.
*
* @category utility types
* @since 0.43.0
*/
export type Bridge = AtomMachine.MachineAtom.Any | AtomMachine.ChildMachineAtom.Any

/**
* The logical snapshot type rendered from a bridge: the machine state for a
* machine bridge and the child state for a child bridge.
*
* @category utility types
* @since 0.43.0
*/
export type State<A extends Bridge> = A extends AtomMachine.MachineAtom.Any ? AtomMachine.MachineAtom.State<A>
: A extends AtomMachine.ChildMachineAtom.Any ? AtomMachine.ChildMachineAtom.State<A>
: never
}

/**
Expand All @@ -52,41 +98,102 @@ export interface MachineStateProps<
* `pending`; an inactive path renders `inactive`. Both default to null.
* Startup and runtime failures propagate to the nearest error boundary.
*
* **Details**
*
* `machine` also accepts a child bridge from `machine.child(Descriptor)`. Paths
* and the callback snapshot are then those of the child machine, and
* `inactive` also renders while the state that owns the child is not active.
*
* **Example**
*
* ```tsx
* <MachineState machine={machine.child(QuantityChild)} path="" inactive={null}>
* {(snapshot) => <NumberField value={snapshot.value.quantity} />}
* </MachineState>
* ```
*
* @category components
* @since 0.32.0
*/
export const MachineState: <
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
Event,
Error,
Output,
StartError,
Emitted,
const Path extends Machine.Snapshot.Path<State>
A extends MachineStateProps.Bridge,
const Path extends Machine.Snapshot.Path<MachineStateProps.State<A>>
>(
props: MachineStateProps<State, Event, Error, Output, StartError, Emitted, Path>
props: MachineStateProps<A, Path>
) => React.ReactNode = internal.MachineState

/** An isolated React owner and accessor for a bridge factory.
* @category models
* @since 0.32.0
*/
export interface MachineContext<Args extends [] | [unknown], A extends AtomMachine.MachineAtom.Any> {
readonly Provider: React.ComponentType<
& { readonly children?: React.ReactNode }
& (Args extends [] ? { readonly input?: never } : { readonly input: Args[0] })
>
export interface MachineContext<Args extends MachineContext.Args, A extends AtomMachine.MachineAtom.Any> {
readonly Provider: React.ComponentType<MachineContext.ProviderProps<Args>>
readonly useMachine: () => A
}

/**
* Type-level members associated with `MachineContext`.
*
* @category models
* @since 0.43.0
*/
export declare namespace MachineContext {
/**
* Parameters a bridge factory may declare: startup input, then a per-owner
* capability layer. Declare the input as `void` for a layer without input.
*
* @category utility types
* @since 0.43.0
*/
export type Args = [] | [input: unknown] | [input: unknown, layer: Layer.Layer<any, any, any>]

/**
* Provider props: `input` is required when the factory declares input and
* `layer` when it declares a layer. Both are rejected otherwise.
*
* @category utility types
* @since 0.43.0
*/
export type ProviderProps<Args extends MachineContext.Args> =
& { readonly children?: React.ReactNode }
& (Args extends [] ? { readonly input?: never }
: [Args[0]] extends [void] ? { readonly input?: never }
: { readonly input: Args[0] })
& (Args extends [unknown, infer L] ? { readonly layer: L } : { readonly layer?: never })
}

/**
* Creates a context from an AtomMachine factory. Each Provider owns a fresh
* bridge in its current registry. Input is read once at startup; use a new React
* key for a new owner. The Provider does not subscribe to machine state.
* bridge in its current registry. The Provider does not subscribe to machine
* state.
*
* **Details**
*
* The factory's first parameter is the Provider's `input` prop. A second
* parameter typed as a `Layer` is the Provider's `layer` prop: a per-owner
* capability, such as a router-bound service, that the factory provides to the
* machine through `AtomMachine.bind(Atom.runtime(...))`. Binding rejects a
* machine whose services the runtime does not provide.
*
* Input and layer are read once, when the Provider mounts. Send an event to
* update a running workflow, or change the Provider's React `key` to create a
* new owner with new input and layer.
*
* **Example**
*
* ```tsx
* const SignIn = createMachineContext(
* (input: SignInInput, navigation: Layer.Layer<Navigation>) =>
* AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer)))
* .make(SignInMachine, input)
* )
*
* <SignIn.Provider input={input} layer={navigationLayer(router)}>...</SignIn.Provider>
* ```
*
* @category constructors
* @since 0.32.0
*/
export const createMachineContext: <Args extends [] | [unknown], A extends AtomMachine.MachineAtom.Any>(
export const createMachineContext: <Args extends MachineContext.Args, A extends AtomMachine.MachineAtom.Any>(
create: (...args: Args) => A
) => MachineContext<Args, A> = internal.createMachineContext
52 changes: 34 additions & 18 deletions packages/effect-machine-react/src/internal/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,34 @@ export const useMachineAtom = <A extends AtomMachine.MachineAtom.Any>(create: ()
}

export const MachineState = <
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
Event,
Error,
Output,
StartError,
Emitted,
const Path extends Machine.Snapshot.Path<State>
A extends MachineStateProps.Bridge,
const Path extends Machine.Snapshot.Path<MachineStateProps.State<A>>
>(
props: MachineStateProps<State, Event, Error, Output, StartError, Emitted, Path>
props: MachineStateProps<A, Path>
): React.ReactNode => {
const selected = React.useMemo(() => AtomMachine.selectSnapshot(props.machine, props.path), [
props.machine,
props.path
])
const result = useRetainedResult(React.useContext(RegistryContext), selected)
const selected = React.useMemo(() => selectSnapshot(props.machine, props.path), [props.machine, props.path])
const result = useMachineResult(selected)
if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause)
if (AsyncResult.isInitial(result)) return props.pending ?? null
return Option.isSome(result.value) ? props.children(result.value.value) : props.inactive ?? null
return Option.isSome(result.value)
? props.children(result.value.value as Machine.Snapshot.At<MachineStateProps.State<A>, Path>)
: props.inactive ?? null
}

// A child bridge yields `Option.none()` both while the state that owns the child
// is inactive and while the path is inactive; both render `inactive`.
const selectSnapshot = (
bridge: MachineStateProps.Bridge,
path: string
): Atom.Atom<AsyncResult.AsyncResult<Option.Option<unknown>, unknown>> =>
AtomMachine.isMachineAtom(bridge)
? AtomMachine.selectSnapshot(bridge, path as never)
: AtomMachine.selectSnapshotChild(bridge, path as never)

export const useMachineResult = <A, E>(
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
): AsyncResult.AsyncResult<A, E> => useRetainedResult(React.useContext(RegistryContext), atom)

/**
* Reads a result atom through `useSyncExternalStore`, so server renders and
* hydration use the atom's server value. Once a renderer has a successful
Expand Down Expand Up @@ -65,13 +73,21 @@ const useRetainedResult = <A, E>(
return React.useSyncExternalStore(store.subscribe, store.snapshot, store.serverSnapshot)
}

export const createMachineContext = <Args extends [] | [unknown], A extends AtomMachine.MachineAtom.Any>(
export const createMachineContext = <Args extends MachineContext.Args, A extends AtomMachine.MachineAtom.Any>(
create: (...args: Args) => A
): MachineContext<Args, A> => {
const Context = React.createContext<A | undefined>(undefined)
const Provider = (props: { readonly input?: unknown; readonly children?: React.ReactNode }): React.ReactNode => {
// Input belongs to this committed owner. A new React key creates a new owner.
const machine = useMachineAtom(() => create(...("input" in props ? [props.input] : []) as Args))
const Provider = (props: {
readonly input?: unknown
readonly layer?: unknown
readonly children?: React.ReactNode
}): React.ReactNode => {
// Input and layer belong to this committed owner. A new React key creates a new owner.
const machine = useMachineAtom(() =>
create(
...("layer" in props ? [props.input, props.layer] : "input" in props ? [props.input] : []) as Args
)
)
return React.createElement(Context.Provider, { value: machine }, props.children)
}
const useMachine = (): A => {
Expand Down
Loading
Loading