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

`MachineState` no longer suspends. Server rendering and hydration render the machine's planned startup snapshot, and the same tree then receives live snapshots, so hydrated form inputs keep their DOM nodes, typed text, and focus. Remove Suspense boundaries that only wrapped `MachineState` for its first paint.

Add the optional `pending` prop, rendered until the machine has a snapshot. Once a snapshot has rendered, `MachineState` keeps it while the machine is still starting, for example while a bound runtime layer builds after hydration:

```tsx
<MachineState machine={machine} path="Editing" pending={<Spinner />}>
{(editing) => <input value={editing.value.email} onChange={onChange} />}
</MachineState>
```
7 changes: 7 additions & 0 deletions .changeset/server-safe-machine-atoms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/effect-machine": minor
---

Reading an `AtomMachine` bridge on the server no longer starts the machine. Every bridge atom now has an Effect Atom server value, so `Atom.getServerValue` and the server render of `@effect/atom-react` hooks return the machine's planned startup state without running startup commands, invocations, or services.

`result` serves `Success(state)` and `snapshot` serves `Success({ status: "active", state })`, both waiting, where `state` is the state `Machine.planInitial` settles into with the bridge's input. `select`, `selectSnapshot`, `matches`, `can`, and `family` projections serve the same projection of that state. `child(Child)` serves the planned child state when the planned parent state invokes `Child`, and `Option.none()` otherwise. `ref`, `send`, and `stop` serve `Initial`, as does every atom of a bridge whose startup state cannot be planned synchronously. Resumed bridges serve their resumed snapshot.
15 changes: 8 additions & 7 deletions packages/effect-machine-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@ React ownership and typed state rendering for
```tsx
import { createMachineContext, MachineState } from "@typeonce/effect-machine-react"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { Suspense } from "react"
import { AuthMachine } from "./auth-machine"

const Auth = createMachineContext(AtomMachine.factory(AuthMachine))

function AuthRoute({ input }: Props) {
return (
<Auth.Provider input={input}>
<Suspense fallback={<Loading />}>
<AuthForm />
</Suspense>
<AuthForm />
</Auth.Provider>
)
}
Expand All @@ -37,9 +34,13 @@ including when two Providers receive equal inputs. Supply an Atom
`AtomMachine.runtime(...)` before creating a factory when the machine needs them.

The Provider owns the reference without subscribing to state changes.
`MachineState` subscribes to its typed path and infers the callback snapshot.
Inactive paths render `inactive` (null by default); startup suspends and failures
propagate to the nearest error boundary. Keep the owner above Suspense.
`MachineState` subscribes to its typed path and infers the callback snapshot
without suspending. Server rendering and hydration render the machine's planned
startup snapshot, and the same tree then receives live snapshots, so hydrated
form inputs keep their DOM nodes, text, and focus. Before any snapshot exists it
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.

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
Expand Down
13 changes: 10 additions & 3 deletions packages/effect-machine-react/src/MachineAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,21 @@ export interface MachineStateProps<
> {
readonly machine: AtomMachine.MachineAtom<State, Event, Error, Output, StartError, Emitted>
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. */
readonly inactive?: React.ReactNode
readonly children: (state: Machine.Snapshot.At<State, NoInfer<Path>>) => React.ReactNode
}

/**
* Subscribes only this renderer to a state path. Startup suspends and failures
* propagate to the nearest error boundary. Inactive child states render
* `inactive`, which defaults to null.
* Subscribes only this renderer to a state path without suspending. Server
* rendering and hydration render the machine's planned startup snapshot, and
* the same tree then receives live snapshots, so form inputs keep their DOM
* nodes, text and focus. Once a snapshot has rendered, the renderer keeps it
* while the machine is still starting. Before any snapshot exists it renders
* `pending`; an inactive path renders `inactive`. Both default to null.
* Startup and runtime failures propagate to the nearest error boundary.
*
* @category components
* @since 0.32.0
Expand Down
41 changes: 37 additions & 4 deletions packages/effect-machine-react/src/internal/react.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useAtomMount, useAtomSuspense } from "@effect/atom-react"
import { RegistryContext, useAtomMount } from "@effect/atom-react"
import type { Machine } from "@typeonce/effect-machine"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { Option } from "effect"
import { Cause, Option } from "effect"
import { AsyncResult, Atom } from "effect/reactivity"
import type { AtomRegistry } from "effect/reactivity"
import * as React from "react"
import type { MachineContext, MachineStateProps } from "../MachineAtom.js"

Expand All @@ -26,8 +28,39 @@ export const MachineState = <
props.machine,
props.path
])
const snapshot = useAtomSuspense(selected).value
return Option.isSome(snapshot) ? props.children(snapshot.value) : props.inactive ?? null
const result = useRetainedResult(React.useContext(RegistryContext), 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
}

/**
* Reads a result atom through `useSyncExternalStore`, so server renders and
* hydration use the atom's server value. Once a renderer has a successful
* result, it keeps that result while the atom is `Initial` again, for example
* while the live machine starts after hydrating its planned snapshot. The
* rendered tree, and every DOM node in it, survives that switch.
*/
const useRetainedResult = <A, E>(
registry: AtomRegistry.AtomRegistry,
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
): AsyncResult.AsyncResult<A, E> => {
const store = React.useMemo(() => {
let retained: AsyncResult.Success<A, E> | undefined
const retain = (result: AsyncResult.AsyncResult<A, E>): AsyncResult.AsyncResult<A, E> => {
if (AsyncResult.isSuccess(result)) {
retained = result
return result
}
return AsyncResult.isInitial(result) && retained !== undefined ? retained : result
}
return {
subscribe: (onChange: () => void) => registry.subscribe(atom, onChange),
snapshot: () => retain(registry.get(atom)),
serverSnapshot: () => retain(Atom.getServerValue(atom, registry))
}
}, [registry, atom])
return React.useSyncExternalStore(store.subscribe, store.snapshot, store.serverSnapshot)
}

export const createMachineContext = <Args extends [] | [unknown], A extends AtomMachine.MachineAtom.Any>(
Expand Down
Loading
Loading