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/atom-send-buffering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@typeonce/effect-machine": minor
---

Deliver `send` and `stop` writes made while an atom machine is still starting. Writes to `MachineAtom.send`, `MachineAtom.stop`, `ChildMachineAtom.send` and `ChildMachineAtom.stop` before readiness are now held and delivered in write order once the machine is ready, instead of being dropped. The write result stays waiting until delivery and fails with the startup error if startup fails. Writes after the machine stopped still fail with `StoppedError`, and child writes still fail with `ChildNotActiveError` when the owning state is inactive.

`NotReadyError` is removed from `AtomMachine` and from the `send` and `stop` failure types. Remove any handling of it.

Add `AtomMachine.MachineAtom.Any` and `AtomMachine.ChildMachineAtom.Any` for code that accepts any bridge, including bridges for machines without events, and the `MachineAtom.State`, `MachineAtom.Event` and `ChildMachineAtom.State` extractors:

```ts
type Bridge = AtomMachine.MachineAtom.Any
type State = AtomMachine.MachineAtom.State<typeof CounterMachineAtom>
type Event = AtomMachine.MachineAtom.Event<typeof CounterMachineAtom>
```
8 changes: 3 additions & 5 deletions packages/effect-machine-react/src/MachineAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import type { AtomMachine } from "@typeonce/effect-machine/reactivity"
import type * as React from "react"
import * as internal from "./internal/react.js"

type AnyMachineAtom = AtomMachine.MachineAtom<any, never, any, any, any, any>

/**
* Creates one bridge per React owner and mounts its reference without subscribing
* the owner to state changes. The factory is startup-only. Change the owner's
Expand All @@ -21,7 +19,7 @@ type AnyMachineAtom = AtomMachine.MachineAtom<any, never, any, any, any, any>
* @category hooks
* @since 0.29.0
*/
export const useMachineAtom: <A extends AnyMachineAtom>(create: () => A) => A = internal.useMachineAtom
export const useMachineAtom: <A extends AtomMachine.MachineAtom.Any>(create: () => A) => A = internal.useMachineAtom

/** Props for a typed render of one active state path.
* @category models
Expand Down Expand Up @@ -66,7 +64,7 @@ export const MachineState: <
* @category models
* @since 0.32.0
*/
export interface MachineContext<Args extends [] | [unknown], A extends AnyMachineAtom> {
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] })
Expand All @@ -82,6 +80,6 @@ export interface MachineContext<Args extends [] | [unknown], A extends AnyMachin
* @category constructors
* @since 0.32.0
*/
export const createMachineContext: <Args extends [] | [unknown], A extends AnyMachineAtom>(
export const createMachineContext: <Args extends [] | [unknown], A extends AtomMachine.MachineAtom.Any>(
create: (...args: Args) => A
) => MachineContext<Args, A> = internal.createMachineContext
6 changes: 2 additions & 4 deletions packages/effect-machine-react/src/internal/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { Option } from "effect"
import * as React from "react"
import type { MachineContext, MachineStateProps } from "../MachineAtom.js"

type AnyMachineAtom = AtomMachine.MachineAtom<any, never, any, any, any, any>

export const useMachineAtom = <A extends AnyMachineAtom>(create: () => A): A => {
export const useMachineAtom = <A extends AtomMachine.MachineAtom.Any>(create: () => A): A => {
const [machine] = React.useState(create)
useAtomMount(machine.ref)
return machine
Expand All @@ -32,7 +30,7 @@ export const MachineState = <
return Option.isSome(snapshot) ? props.children(snapshot.value) : props.inactive ?? null
}

export const createMachineContext = <Args extends [] | [unknown], A extends AnyMachineAtom>(
export const createMachineContext = <Args extends [] | [unknown], A extends AtomMachine.MachineAtom.Any>(
create: (...args: Args) => A
): MachineContext<Args, A> => {
const Context = React.createContext<A | undefined>(undefined)
Expand Down
42 changes: 40 additions & 2 deletions packages/effect-machine-react/test/MachineAtom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { RegistryContext, useAtomSuspense } from "@effect/atom-react"
import { RegistryContext, useAtomSet, useAtomSuspense } from "@effect/atom-react"
import { act, cleanup, render, screen, waitFor } from "@testing-library/react"
import { Effect, Equal, Option, Schema } from "effect"
import { Effect, Equal, Layer, Option, Schema } from "effect"
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"
import * as React from "react"
import { renderToString } from "react-dom/server"
Expand Down Expand Up @@ -243,6 +243,44 @@ describe("useMachineAtom", () => {
})
registry.dispose()
})
it("delivers events sent on first mount while a bound runtime is still building", async () => {
const machine = trackedMachine(() => {})
const runtime = Atom.runtime(Layer.effectDiscard(Effect.sleep("20 millis")))
const registry = AtomRegistry.make()
function Reader({ owned }: { readonly owned: AtomMachine.MachineAtom.Any }) {
const active = useAtomSuspense(AtomMachine.matches(owned, "Active")).value
return <span data-testid="active">{String(active)}</span>
}
function Owner() {
const owned = useMachineAtom(() => AtomMachine.bind(runtime).make(machine, 3))
const send = useAtomSet(owned.send)
React.useEffect(() => {
send(new Increment({}))
send(new Increment({}))
}, [send])
const value = AtomMachine.select(owned, "Active")
return (
<React.Suspense fallback="starting">
<Reader owned={owned} />
<Value value={value} />
</React.Suspense>
)
}
function Value({ value }: {
readonly value: Atom.Atom<AsyncResult.AsyncResult<Option.Option<Active>, unknown>>
}) {
const active = useAtomSuspense(value).value
return <span data-testid="value">{Option.getOrThrow(active).value}</span>
}
render(
<RegistryContext.Provider value={registry}>
<Owner />
</RegistryContext.Provider>
)
await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "5"))
assert.strictEqual(screen.getByTestId("active").textContent, "true")
registry.dispose()
})
it("does not start the machine during server rendering", () => {
let starts = 0
const machine = trackedMachine(() => {
Expand Down
29 changes: 29 additions & 0 deletions packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ function SubmitButton() {
`useAtomSet` mounts the writable atom and does not subscribe the component to
its value.

Writes made while the machine is still starting, for example while a bound
`Atom.runtime` layer is building after hydration, are held and delivered in
write order once the machine is ready. The write result stays waiting until
the event is delivered and fails with the startup error if startup fails.
Writes after the machine stopped or completed fail with `StoppedError`. The
same applies to `stop` and to child `send` and `stop`; a child command fails
with `ChildNotActiveError` when the state that owns the child is not active.

## Query concrete event acceptance

`AtomMachine.can` turns one concrete event input into a reusable machine
Expand Down Expand Up @@ -328,6 +336,27 @@ export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, {
})
```

## Generic helpers

Use `AtomMachine.MachineAtom.Any` and `AtomMachine.ChildMachineAtom.Any` to
accept any bridge, including bridges for machines without events. Recover the
exact types with the namespace extractors:

```ts
function StateView<A extends AtomMachine.MachineAtom.Any>(props: {
readonly machine: A
readonly render: (state: AtomMachine.MachineAtom.State<A>) => React.ReactNode
}) {
const state = useAtomSuspense(props.machine.result).value
return props.render(state)
}

type AuthEvent = AtomMachine.MachineAtom.Event<typeof AuthMachineAtom>
type EditorState = AtomMachine.ChildMachineAtom.State<
AtomMachine.ChildOf<typeof AuthMachineAtom, typeof Editor>
>
```

## Registry and rendering semantics

A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same
Expand Down
Loading
Loading