diff --git a/.changeset/hydration-safe-machine-state.md b/.changeset/hydration-safe-machine-state.md
new file mode 100644
index 0000000..c9b5cf0
--- /dev/null
+++ b/.changeset/hydration-safe-machine-state.md
@@ -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
+}>
+ {(editing) => }
+
+```
diff --git a/.changeset/server-safe-machine-atoms.md b/.changeset/server-safe-machine-atoms.md
new file mode 100644
index 0000000..412acea
--- /dev/null
+++ b/.changeset/server-safe-machine-atoms.md
@@ -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.
diff --git a/packages/effect-machine-react/README.md b/packages/effect-machine-react/README.md
index a63fbd3..b9ebb4d 100644
--- a/packages/effect-machine-react/README.md
+++ b/packages/effect-machine-react/README.md
@@ -6,7 +6,6 @@ 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))
@@ -14,9 +13,7 @@ const Auth = createMachineContext(AtomMachine.factory(AuthMachine))
function AuthRoute({ input }: Props) {
return (
- }>
-
-
+
)
}
@@ -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
diff --git a/packages/effect-machine-react/src/MachineAtom.ts b/packages/effect-machine-react/src/MachineAtom.ts
index 79d2426..18bf016 100644
--- a/packages/effect-machine-react/src/MachineAtom.ts
+++ b/packages/effect-machine-react/src/MachineAtom.ts
@@ -36,14 +36,21 @@ export interface MachineStateProps<
> {
readonly machine: AtomMachine.MachineAtom
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>) => 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
diff --git a/packages/effect-machine-react/src/internal/react.ts b/packages/effect-machine-react/src/internal/react.ts
index 2a229ce..a580e14 100644
--- a/packages/effect-machine-react/src/internal/react.ts
+++ b/packages/effect-machine-react/src/internal/react.ts
@@ -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"
@@ -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 = (
+ registry: AtomRegistry.AtomRegistry,
+ atom: Atom.Atom>
+): AsyncResult.AsyncResult => {
+ const store = React.useMemo(() => {
+ let retained: AsyncResult.Success | undefined
+ const retain = (result: AsyncResult.AsyncResult): AsyncResult.AsyncResult => {
+ 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 = (
diff --git a/packages/effect-machine-react/test/ServerRendering.test.tsx b/packages/effect-machine-react/test/ServerRendering.test.tsx
new file mode 100644
index 0000000..78270bc
--- /dev/null
+++ b/packages/effect-machine-react/test/ServerRendering.test.tsx
@@ -0,0 +1,267 @@
+// @vitest-environment jsdom
+import { RegistryContext } from "@effect/atom-react"
+import { act, cleanup, render, screen } from "@testing-library/react"
+import { Context, Deferred, Effect, Layer, Schema } from "effect"
+import { Atom, AtomRegistry } from "effect/reactivity"
+import * as React from "react"
+import { hydrateRoot } from "react-dom/client"
+import { renderToString } from "react-dom/server"
+import { afterEach, assert, it } from "vitest"
+import { Machine } from "../../effect-machine/src/index.js"
+import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/index.js"
+import { createMachineContext, MachineState } from "../src/index.js"
+
+class Analytics extends Context.Service
+}>()("test/ServerRendering/Analytics") {}
+
+const Events = Machine.events({ Changed: { email: Schema.String } })
+
+const makeOnboarding = () => {
+ const counter = { started: 0 }
+ const machine = Machine.make({
+ root: Machine.state({
+ fields: { seed: Schema.String },
+ states: { Editing: { fields: { email: Schema.String } }, Saved: {} }
+ }),
+ events: Events,
+ input: Schema.String,
+ effects: {
+ track: Effect.sync(() => {
+ counter.started++
+ }).pipe(Effect.andThen(Effect.never))
+ }
+ }).handle({
+ root: ({ input }) => ({ seed: input }),
+ initial: { target: "Editing", data: ({ root }) => ({ email: root.seed }) },
+ states: {
+ Editing: {
+ invoke: { src: "track" },
+ on: { Changed: { target: "Editing", data: ({ event }) => ({ email: event.email }) } }
+ },
+ Saved: {}
+ }
+ })
+ return { counter, machine }
+}
+
+const makeAnalyticsOnboarding = () =>
+ Machine.make({
+ root: Machine.state({ states: { Editing: { fields: { email: Schema.String } } } }),
+ events: Events,
+ effects: {
+ track: Effect.gen(function*() {
+ const analytics = yield* Analytics
+ yield* analytics.started
+ return yield* Effect.never
+ })
+ }
+ }).handle({
+ initial: { target: "Editing", data: { email: "planned@example.com" } },
+ states: {
+ Editing: {
+ invoke: { src: "track" },
+ on: { Changed: { target: "Editing", data: ({ event }) => ({ email: event.email }) } }
+ }
+ }
+ })
+
+afterEach(cleanup)
+
+const hydrate = async (container: HTMLElement, element: React.ReactNode) => {
+ const recoverable: Array = []
+ const root = await act(async () =>
+ hydrateRoot(container, element, {
+ onRecoverableError: (error) => {
+ recoverable.push(error)
+ }
+ })
+ )
+ return { recoverable, root }
+}
+
+it("renders the planned snapshot on the server and hydrates into the live machine", async () => {
+ const { counter, machine } = makeOnboarding()
+ const Onboarding = createMachineContext(AtomMachine.factory(machine))
+ let bridge: ReturnType | undefined
+ function EmailField() {
+ bridge = Onboarding.useMachine()
+ return (
+ pending}>
+ {(editing) => {}} />}
+
+ )
+ }
+ const tree = (registry: AtomRegistry.AtomRegistry) => (
+
+
+
+
+
+ )
+
+ const serverRegistry = AtomRegistry.make()
+ const html = renderToString(tree(serverRegistry))
+ assert.strictEqual(html, "")
+ assert.strictEqual(counter.started, 0)
+ serverRegistry.dispose()
+
+ const container = document.createElement("div")
+ document.body.appendChild(container)
+ container.innerHTML = html
+ const input = container.querySelector("input")!
+ input.focus()
+
+ const clientRegistry = AtomRegistry.make()
+ const { recoverable, root } = await hydrate(container, tree(clientRegistry))
+ assert.deepStrictEqual(recoverable, [])
+ assert.strictEqual(counter.started, 1)
+ assert.strictEqual(container.querySelector("input"), input)
+ assert.strictEqual(document.activeElement, input)
+
+ await act(async () => {
+ clientRegistry.set(bridge!.send, Events.Changed({ email: "grace@example.com" }))
+ })
+ assert.strictEqual(container.querySelector("input"), input)
+ assert.strictEqual(input.value, "grace@example.com")
+ assert.strictEqual(document.activeElement, input)
+ assert.strictEqual(counter.started, 1)
+
+ await act(async () => root.unmount())
+ container.remove()
+ clientRegistry.dispose()
+})
+
+it("keeps the hydrated tree while a bound runtime is still building", async () => {
+ const release = Effect.runSync(Deferred.make())
+ let started = 0
+ const layer = Layer.effect(
+ Analytics,
+ Deferred.await(release).pipe(Effect.as(Analytics.of({ started: Effect.sync(() => started++) })))
+ )
+ const Onboarding = createMachineContext(
+ AtomMachine.bind(Atom.runtime(layer)).factory(makeAnalyticsOnboarding())
+ )
+ let bridge: ReturnType | undefined
+ function EmailField() {
+ bridge = Onboarding.useMachine()
+ return (
+ pending}>
+ {(editing) => {}} />}
+
+ )
+ }
+ const tree = (registry: AtomRegistry.AtomRegistry) => (
+
+
+
+
+
+ )
+
+ const serverRegistry = AtomRegistry.make()
+ const html = renderToString(tree(serverRegistry))
+ assert.strictEqual(html, "")
+ serverRegistry.dispose()
+
+ const container = document.createElement("div")
+ document.body.appendChild(container)
+ container.innerHTML = html
+ const input = container.querySelector("input")!
+
+ const clientRegistry = AtomRegistry.make()
+ const { recoverable, root } = await hydrate(container, tree(clientRegistry))
+ assert.deepStrictEqual(recoverable, [])
+ assert.strictEqual(started, 0)
+ assert.strictEqual(container.querySelector("input"), input)
+ assert.strictEqual(input.value, "planned@example.com")
+
+ await act(async () => {
+ clientRegistry.set(bridge!.send, Events.Changed({ email: "live@example.com" }))
+ Effect.runSync(Deferred.succeed(release, undefined))
+ })
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ assert.strictEqual(started, 1)
+ assert.strictEqual(container.querySelector("input"), input)
+ assert.strictEqual(input.value, "live@example.com")
+
+ await act(async () => root.unmount())
+ container.remove()
+ clientRegistry.dispose()
+})
+
+it("renders pending until a client-only machine has a snapshot", async () => {
+ const release = Effect.runSync(Deferred.make())
+ const layer = Layer.effect(
+ Analytics,
+ Deferred.await(release).pipe(Effect.as(Analytics.of({ started: Effect.void })))
+ )
+ const Onboarding = createMachineContext(
+ AtomMachine.bind(Atom.runtime(layer)).factory(makeAnalyticsOnboarding())
+ )
+ function EmailField() {
+ return (
+ pending}>
+ {(editing) => {editing.value.email}}
+
+ )
+ }
+ const registry = AtomRegistry.make()
+ render(
+
+
+
+
+
+ )
+ await screen.findByText("pending")
+ await act(async () => {
+ Effect.runSync(Deferred.succeed(release, undefined))
+ })
+ await screen.findByText("planned@example.com")
+ registry.dispose()
+})
+
+it("renders nothing on the server for a machine that cannot be planned and fails on the client", async () => {
+ const { counter, machine } = makeOnboarding()
+ const Onboarding = createMachineContext(AtomMachine.factory(machine))
+ let caught: unknown
+ class Boundary extends React.Component {
+ state = { failed: false }
+ static getDerivedStateFromError(error: unknown) {
+ caught = error
+ return { failed: true }
+ }
+ render() {
+ return this.state.failed ? startup failed : this.props.children
+ }
+ }
+ function EmailField() {
+ return (
+
+ {(editing) => {editing.value.email}}
+
+ )
+ }
+ const tree = (registry: AtomRegistry.AtomRegistry) => (
+
+
+
+
+
+
+
+ )
+ const serverRegistry = AtomRegistry.make()
+ assert.strictEqual(renderToString(tree(serverRegistry)), "")
+ serverRegistry.dispose()
+
+ const registry = AtomRegistry.make()
+ render(tree(registry))
+ await screen.findByText("startup failed")
+ assert.isDefined(caught)
+ assert.strictEqual(counter.started, 0)
+ registry.dispose()
+})
diff --git a/packages/effect-machine-react/typetest/MachineAtom.tst.ts b/packages/effect-machine-react/typetest/MachineAtom.tst.ts
index e309afa..60f75a1 100644
--- a/packages/effect-machine-react/typetest/MachineAtom.tst.ts
+++ b/packages/effect-machine-react/typetest/MachineAtom.tst.ts
@@ -78,3 +78,10 @@ MachineState({
}
})
expect(MachineState).type.not.toBeCallableWith({ machine: contextBridge, path: "Missing", children: () => null })
+expect(MachineState).type.toBeCallableWith({
+ machine: contextBridge,
+ path: "Editing",
+ pending: "starting",
+ inactive: null,
+ children: () => null
+})
diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md
index 643e246..5143cff 100644
--- a/packages/effect-machine/docs/effect-atom-react.md
+++ b/packages/effect-machine/docs/effect-atom-react.md
@@ -53,18 +53,18 @@ export const useAuthMachine = Auth.useMachine
function AuthRoute({ input, attemptId }: Props) {
return (
- }>
-
-
+
)
}
```
Input is startup-only. Send events to update the existing workflow; change the
-Provider's React key to create a new one. Keep the Provider above Suspense so
-suspension does not discard the owner. Supply an Atom RegistryProvider at the
-application boundary. The same factory used by two Providers creates isolated
+Provider's React key to create a new one. `MachineState` does not suspend, so
+the first paint needs no Suspense boundary. Custom readers that use
+`useAtomSuspense` still suspend during startup; keep the Provider above their
+Suspense boundary so suspension does not discard the owner. Supply an Atom
+RegistryProvider at the application boundary. The same factory used by two Providers creates isolated
machines, even when their inputs are equal.
`useMachineAtom(() => makeMachine(input))` remains available for custom owners.
@@ -155,8 +155,11 @@ function EditingFields() {
```
`MachineState` subscribes only its renderer. The path determines the callback's
-snapshot type. Startup suspends, failures reach the nearest error boundary,
-and an inactive path renders `inactive` (null by default).
+snapshot type. It does not suspend: until the machine has a snapshot it renders
+`pending`, and an inactive path renders `inactive` (both null by default).
+Startup and runtime failures reach the nearest error boundary. Once a snapshot
+has rendered, the renderer keeps it while the machine is still starting, for
+example while a bound runtime layer builds after hydration.
For custom hooks, `useAtomSuspense(AtomMachine.selectSnapshot(machine, path)).value`
returns an Option of the selected snapshot.
@@ -492,6 +495,44 @@ after its final subscription and configured idle retention expire.
`registry.dispose()` stops it immediately.
`useMachineAtom` does not start a machine during server rendering because
-React effects do not run on the server. Reading a machine atom during server
-render follows `@effect/atom-react` server-read behavior, so choose an explicit
-client boundary when server startup would be undesirable.
+React effects do not run on the server. Server reads never start a machine
+either. Every bridge atom carries an Effect Atom server value, which
+`@effect/atom-react` hooks read during server rendering and hydration, and
+`Atom.getServerValue` reads directly:
+
+| Atom | Server value |
+| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `ref`, `send`, `stop` | `Initial` |
+| `snapshot` | `Success({ status: "active", state })`, waiting |
+| `result` | `Success(state)`, waiting |
+| `select`, `selectSnapshot`, `matches`, `can` | the projection of `state`, waiting |
+| `child(Child).result` and child projections | `Success(Option.some(childState))` when the planned state invokes `Child`, otherwise `Success(Option.none())` |
+| `family` and `familyChild` projections | the server value of the projected bridge atom |
+
+`state` is the machine's planned startup state: the state `Machine.planInitial`
+settles into with the bridge's input, without running startup commands,
+invocations, or services. A resumed bridge uses its resumed snapshot. Children
+spawned at runtime or through child families are `Option.none()`. When the
+startup state cannot be planned synchronously, for example because the input
+is invalid, every server value is `Initial`. Each bridge plans at most once,
+on its first server read.
+
+## Server rendering and hydration
+
+Render state slots with `MachineState` and no Suspense boundary:
+
+```tsx
+
+
+ {(editing) => }
+
+
+```
+
+The server renders the planned `Editing` snapshot. Hydration renders the same
+markup, then the Provider mounts the machine and the same component tree
+receives live snapshots. The input keeps its DOM node, typed text, and focus.
+
+Do not wrap hydrated forms in a Suspense boundary that a startup read can
+trigger. A suspended boundary replaces its server-rendered content with the
+fallback and later mounts new DOM nodes, discarding typed text and focus.
diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts
index d88253e..483682f 100644
--- a/packages/effect-machine/src/internal/machine/atom.ts
+++ b/packages/effect-machine/src/internal/machine/atom.ts
@@ -17,6 +17,7 @@ import * as Stream from "effect/Stream"
import type * as Machine from "../../Machine.js"
import type { Bound, ChildMachineAtom, MachineAtom } from "../../unstable/reactivity/AtomMachine.js"
import * as internalMachine from "./machine.js"
+import * as PlannedSnapshot from "./plannedSnapshot.js"
import type { EnsureExecutable } from "./readiness.js"
import type { ExcludeCompatibleRuntime as ExcludeCompatibleMachineRuntime, IsAny } from "./requirements.js"
import * as Topology from "./topology.js"
@@ -140,6 +141,47 @@ const resumeMachineAtomEffect = (
snapshot: Machine.Machine.Snapshot
) => runMachineAtomEffect(get, internalMachine.resume(machine, snapshot))
+// Server reads. A server read renders the planned startup snapshot and never
+// evaluates a bridge ref, because evaluating it starts the machine. Effect
+// passes plain registry reads to a server value, so every exported atom carries
+// its own server value derived from the bridge plan.
+
+type ServerRead = (atom: Atom.Atom) => A
+
+interface ServerValued {
+ readonly [Atom.ServerValueTypeId]: (get: ServerRead) => A
+}
+
+// Effect stores the server read on the atom without exposing it in `Atom`'s
+// type. This is the only erased boundary of the server protocol.
+const readServerValue = (atom: Atom.Atom, get: ServerRead): A =>
+ Atom.ServerValueTypeId in atom ? (atom as Atom.Atom & ServerValued)[Atom.ServerValueTypeId](get) : get(atom)
+
+/** A bridge plan: `undefined` when no snapshot can be planned synchronously. */
+type ServerPlan = () => PlannedSnapshot.PlannedSnapshot | undefined
+
+/**
+ * A child bridge plan: `Option.none()` when the planned parent does not run
+ * the child, `undefined` when the parent or child cannot be planned.
+ */
+type ChildServerPlan = () => Option.Option | undefined
+
+// Plans are computed on the first server read, at most once per bridge, so
+// client starts never pay for them.
+const lazyPlan = (plan: () => A): () => A => {
+ let planned: { readonly value: A } | undefined
+ return () => {
+ if (planned === undefined) planned = { value: plan() }
+ return planned.value
+ }
+}
+
+// Planned snapshots are erased to the machine's snapshot schema; the bridge
+// that owns a plan types its state, error and output.
+const plannedSnapshot = (
+ planned: PlannedSnapshot.PlannedSnapshot
+): Machine.RuntimeSnapshot => planned.snapshot as Machine.RuntimeSnapshot
+
type RefState[ = Ref extends Machine.MachineRef ? State : never
type RefError][ = Ref extends Machine.MachineRef ? Error : never
type RefOutput][ = Ref extends Machine.MachineRef ? Output : never
@@ -263,7 +305,7 @@ const makeCommandAtom = ][(
queues: CommandQueues,
command: (ref: Ref, value: W) => Effect.Effect
): Atom.Writable, W> =>
- Atom.writable(
+ Atom.writable, W>(
(get): AsyncResult.AsyncResult => {
const current = AsyncResult.map(get(ref), () => undefined)
const queue = queues.get(get.registry)
@@ -303,7 +345,7 @@ const makeCommandAtom = ][(
})
}
}
- )
+ ).pipe(Atom.withServerValueInitial)
const makeRuntimeResultAtom = (
snapshot: Atom.Atom, StartError>>
@@ -326,7 +368,11 @@ const makeRuntimeResultAtom = (
return AsyncResult.success(current.value.state, {
waiting: current.waiting
})
- }).pipe(Atom.withEquality(Equal.equals))
+ }).pipe(
+ Atom.withEquality(Equal.equals),
+ // A planned snapshot is never an error, so its state is the server result.
+ Atom.withServerValue((get) => AsyncResult.map(readServerValue(snapshot, get), (current) => current.state))
+ )
const makeChildRuntimeResultAtom = (
snapshot: Atom.Atom<
@@ -359,7 +405,12 @@ const makeChildRuntimeResultAtom = (
return AsyncResult.success(Option.some(current.value.value.state), {
waiting: current.waiting
})
- }).pipe(Atom.withEquality(Equal.equals))
+ }).pipe(
+ Atom.withEquality(Equal.equals),
+ Atom.withServerValue((get) =>
+ AsyncResult.map(readServerValue(snapshot, get), Option.map((current) => current.state))
+ )
+ )
const makeChildRefAtom = (
parentRef: Atom.Atom<
@@ -367,7 +418,7 @@ const makeChildRefAtom = (
>,
child: Child
): Atom.Atom>, StartError>> =>
- Atom.readable((get) => {
+ Atom.readable((get): AsyncResult.AsyncResult>, StartError> => {
const parent = get(parentRef)
if (AsyncResult.isInitial(parent)) {
return AsyncResult.initial(parent.waiting)
@@ -393,11 +444,12 @@ const makeChildRefAtom = (
)
get.addFinalizer(cancel)
return AsyncResult.success(current)
- })
+ }).pipe(Atom.withServerValueInitial)
const makeChildFromRefAtom = (
ref: Atom.Atom>, StartError>>,
- descriptor: Child
+ descriptor: Child,
+ plan: ChildServerPlan
): ChildMachineAtom => {
type Ref = Machine.ChildMachine.Ref
type State = RefState][
@@ -428,7 +480,17 @@ const makeChildFromRefAtom = >,
+ StartError
+ > => {
+ const planned = plan()
+ return planned === undefined
+ ? AsyncResult.initial(true)
+ : AsyncResult.success(Option.map(planned, plannedSnapshot), { waiting: true })
+ })
+ )
const commands: CommandQueues = new WeakMap()
const activeChild = (current: Option.Option][): Effect.Effect][ =>
@@ -449,7 +511,7 @@ const makeChildFromRefAtom = Effect.flatMap(activeChild(current), (child) => child.stop)
)
- const child = makeChildSelector(ref as any)
+ const child = makeChildSelector(ref as any, plan)
return {
ref,
@@ -464,7 +526,8 @@ const makeChildFromRefAtom = (
parentRef: Atom.Atom<
AsyncResult.AsyncResult>, StartError>
- >
+ >,
+ parentPlan: ChildServerPlan
) => {
const byMachine = new WeakMap]