From 180df4ddddfa53e0f1ff3440a2060149a55e6921 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:36:48 +0000 Subject: [PATCH] Serve planned snapshots from machine atoms on the server Every AtomMachine bridge atom now declares an Effect Atom server value derived from one lazily planned startup snapshot per bridge, so server reads never start a machine. MachineState renders through useSyncExternalStore without suspending, hydrating the planned snapshot into the live machine without remounting form inputs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L --- .changeset/hydration-safe-machine-state.md | 13 + .changeset/server-safe-machine-atoms.md | 7 + packages/effect-machine-react/README.md | 15 +- .../effect-machine-react/src/MachineAtom.ts | 13 +- .../src/internal/react.ts | 41 ++- .../test/ServerRendering.test.tsx | 267 ++++++++++++++++ .../typetest/MachineAtom.tst.ts | 7 + .../effect-machine/docs/effect-atom-react.md | 63 +++- .../src/internal/machine/atom.ts | 187 +++++++++--- .../src/internal/machine/plannedSnapshot.ts | 113 +++++++ .../src/internal/machine/planner.ts | 2 +- .../reactivity/AtomMachineServer.test.ts | 288 ++++++++++++++++++ 12 files changed, 947 insertions(+), 69 deletions(-) create mode 100644 .changeset/hydration-safe-machine-state.md create mode 100644 .changeset/server-safe-machine-atoms.md create mode 100644 packages/effect-machine-react/test/ServerRendering.test.tsx create mode 100644 packages/effect-machine/src/internal/machine/plannedSnapshot.ts create mode 100644 packages/effect-machine/test/unstable/reactivity/AtomMachineServer.test.ts 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 f407c39..e936c96 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. @@ -88,8 +88,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. @@ -366,6 +369,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 ChildMachineAtom>() return (descriptor: Child): ChildMachineAtom => { @@ -475,7 +538,13 @@ const makeChildSelector = ( const child = internalMachine.child(id, machine) return makeChildFromRefAtom( makeChildRefAtom(parentRef as any, child), - child + child, + lazyPlan(() => { + const parent = parentPlan() + return parent === undefined || Option.isNone(parent) + ? parent + : PlannedSnapshot.planInvokedChild(parent.value, child) + }) ) }) family = (id) => atoms(id) as ChildMachineAtom @@ -486,9 +555,11 @@ const makeChildSelector = ( } const makeFromRefAtom = ( - ref: Atom.Atom, StartError>>, - machine: Machine.Machine.Any + liveRef: Atom.Atom, StartError>>, + machine: Machine.Machine.Any, + plan: ServerPlan ): MachineAtom => { + const ref = Atom.withServerValueInitial(liveRef) const snapshot = Atom.readable(( get ): AsyncResult.AsyncResult, StartError> => { @@ -522,7 +593,14 @@ const makeFromRefAtom = ( return AsyncResult.success(current, { waiting: current.status === "active" }) - }) + }).pipe( + Atom.withServerValue((): AsyncResult.AsyncResult, StartError> => { + const planned = plan() + return planned === undefined + ? AsyncResult.initial(true) + : AsyncResult.success(plannedSnapshot(planned), { waiting: true }) + }) + ) const commands: CommandQueues = new WeakMap() @@ -539,7 +617,10 @@ const makeFromRefAtom = ( ) const optionalRef = Atom.mapResult(ref, Option.some) - const child = makeChildSelector(optionalRef as any) + const child = makeChildSelector(optionalRef as any, () => { + const planned = plan() + return planned === undefined ? undefined : Option.some(planned) + }) const result = { ref, @@ -609,6 +690,16 @@ type SelectorKind = | "selectSnapshot" | "selectSnapshotChild" +// Selectors project the bridge result live and its planned result on the server. +const mapResultSelector = ( + source: Atom.Atom>, + f: (value: A) => B +): Atom.Atom> => + Atom.mapResult(source, f).pipe( + Atom.withEquality(Equal.equals), + Atom.withServerValue((get) => AsyncResult.map(readServerValue(source, get), f)) + ) + const selectorsByBridge = new WeakMap>>>() const cachedSelector = ( @@ -654,10 +745,7 @@ export const select = < self, "select", path, - () => - Atom.mapResult(self.result, (snapshot) => selectValueByPath(snapshot, path)).pipe( - Atom.withEquality(Equal.equals) - ) + () => mapResultSelector(self.result, (snapshot) => selectValueByPath(snapshot, path)) ) export const selectSnapshot = < @@ -678,10 +766,7 @@ export const selectSnapshot = < self, "selectSnapshot", path, - () => - Atom.mapResult(self.result, (snapshot) => selectSnapshotByPath(snapshot, path)).pipe( - Atom.withEquality(Equal.equals) - ) + () => mapResultSelector(self.result, (snapshot) => selectSnapshotByPath(snapshot, path)) ) export const selectChild = < @@ -698,10 +783,10 @@ export const selectChild = < > > => cachedSelector(self, "selectChild", path, () => - Atom.mapResult( + mapResultSelector( self.result, Option.flatMap((snapshot) => selectValueByPath(snapshot, path)) - ).pipe(Atom.withEquality(Equal.equals))) + )) export const selectSnapshotChild = < Child extends Machine.ChildMachine.Any, @@ -717,10 +802,10 @@ export const selectSnapshotChild = < > > => cachedSelector(self, "selectSnapshotChild", path, () => - Atom.mapResult( + mapResultSelector( self.result, Option.flatMap((snapshot) => selectSnapshotByPath(snapshot, path)) - ).pipe(Atom.withEquality(Equal.equals))) + )) export const matches = < State extends Machine.Machine.AtomicSnapshot, @@ -738,10 +823,7 @@ export const matches = < self, "matches", path, - () => - Atom.mapResult(self.result, (snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))).pipe( - Atom.withEquality(Equal.equals) - ) + () => mapResultSelector(self.result, (snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))) ) export const matchesChild = < @@ -755,10 +837,10 @@ export const matchesChild = < AsyncResult.AsyncResult>> > => cachedSelector(self, "matchesChild", path, () => - Atom.mapResult( + mapResultSelector( self.result, Option.exists((snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))) - ).pipe(Atom.withEquality(Equal.equals))) + )) export const can = (event: unknown) => { const byBridge = new WeakMap>>() @@ -774,9 +856,11 @@ export const can = (event: unknown) => { ? () => Effect.die(new Error("AtomMachine.can requires a machine atom created by AtomMachine")) : internalMachine.can(machine) - const result = Atom.readable((get): AsyncResult.AsyncResult => { - const current = get(self.snapshot) - const previous = get.self>() + const evaluate = ( + current: AsyncResult.AsyncResult, any>, + input: unknown, + previous: Option.Option> + ): AsyncResult.AsyncResult => { if (AsyncResult.isInitial(current)) { return AsyncResult.initial(current.waiting) } else if (AsyncResult.isFailure(current)) { @@ -793,7 +877,6 @@ export const can = (event: unknown) => { 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 }) @@ -801,7 +884,24 @@ export const can = (event: unknown) => { previous, waiting: current.waiting }) - }).pipe(Atom.withEquality(Equal.equals)) + } + + const result = Atom.readable((get): AsyncResult.AsyncResult => + evaluate( + get(self.snapshot), + Atom.isAtom(event) ? get(event) : event, + get.self>() + ) + ).pipe( + Atom.withEquality(Equal.equals), + Atom.withServerValue((get) => + evaluate( + readServerValue(self.snapshot, get), + Atom.isAtom(event) ? readServerValue(event, get) : event, + Option.none() + ) + ) + ) byBridge.set(self, result) return result @@ -888,7 +988,7 @@ export const make: { } = ((machine: Machine.Machine.Any, ...args: ReadonlyArray) => { const prepared = Atom.make(() => internalMachine.prepare(machine, ...args)) const ref = Atom.make((get) => startPreparedMachineAtomEffect(get, prepared as any)) - const result = makeFromRefAtom(ref as any, machine) + const result = makeFromRefAtom(ref as any, machine, lazyPlan(() => PlannedSnapshot.planStart(machine, args))) preparedByMachineAtom.set(result, prepared as any) return result }) as any @@ -907,7 +1007,7 @@ export const resume: { ): ResumedMachineAtomOf } = ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot) => { const ref = Atom.make((get) => resumeMachineAtomEffect(get, machine, snapshot)) - return makeFromRefAtom(ref as any, machine) + return makeFromRefAtom(ref as any, machine, lazyPlan(() => PlannedSnapshot.planResume(machine, snapshot))) }) as any const makeWithRuntime = ( @@ -917,7 +1017,7 @@ const makeWithRuntime = ( ): MachineAtom => { const prepared = runtime.atom(() => internalMachine.prepare(machine, ...args)) const ref = runtime.atom((get) => startPreparedMachineAtomEffect(get, prepared as any)) - const result = makeFromRefAtom(ref as any, machine) + const result = makeFromRefAtom(ref as any, machine, lazyPlan(() => PlannedSnapshot.planStart(machine, args))) preparedByMachineAtom.set(result, prepared as any) return result } @@ -928,7 +1028,7 @@ const resumeWithRuntime = ( snapshot: Machine.Machine.Snapshot ): MachineAtom => { const ref = runtime.atom((get) => resumeMachineAtomEffect(get, machine, snapshot)) - return makeFromRefAtom(ref as any, machine) + return makeFromRefAtom(ref as any, machine, lazyPlan(() => PlannedSnapshot.planResume(machine, snapshot))) } type FamilyBridge = MachineAtom.Any | ChildMachineAtom.Any @@ -949,7 +1049,8 @@ const retainFamilyOwner = >( (get) => get(retained.source), { initialValueTarget: source } ).pipe( - Atom.withEquality((value, next) => retained.source.equals(value, next)) + Atom.withEquality((value, next) => retained.source.equals(value, next)), + Atom.withServerValue((get) => readServerValue(retained.source, get)) ) if (label !== undefined) { atom = atom.pipe(Atom.withLabel(label)) diff --git a/packages/effect-machine/src/internal/machine/plannedSnapshot.ts b/packages/effect-machine/src/internal/machine/plannedSnapshot.ts new file mode 100644 index 0000000..bf404ed --- /dev/null +++ b/packages/effect-machine/src/internal/machine/plannedSnapshot.ts @@ -0,0 +1,113 @@ +/** + * Startup snapshots planned without running a machine. + * + * Server reads of atom bridges render these snapshots instead of starting the + * runtime. Every function here is synchronous, executes no machine command and + * reports a plan that cannot be produced synchronously as `undefined`. + * + * @internal + */ + +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import type { ChildMachine, ChildOwner, Machine, RuntimeSnapshot } from "../../Machine.js" +import * as Configuration from "./configuration.js" +import { StoppedError } from "./errors.js" +import * as InvocationDefinition from "./invocationDefinition.js" +import * as Planner from "./planner.js" +import * as Serialization from "./serialization.js" + +/** A machine paired with the runtime snapshot it is planned to start with. */ +export interface PlannedSnapshot { + readonly machine: Machine.Any + readonly snapshot: RuntimeSnapshot, never, unknown> +} + +/** + * Plans the snapshot a fresh start settles into, following the generic + * planner. Startup commands are planned and discarded. + */ +export const planStart = (machine: Machine.Any, args: ReadonlyArray): PlannedSnapshot | undefined => { + try { + const planned = Planner.planInitialSync(machine, ...args) + return { + machine, + snapshot: planned.done + ? { status: "done", state: planned.state, output: planned.output } + : { status: "active", state: planned.state } + } + } catch { + return undefined + } +} + +/** Plans the snapshot a resumed machine starts from. */ +export const planResume = (machine: Machine.Any, snapshot: Machine.Snapshot): PlannedSnapshot | undefined => { + try { + const normalized = Effect.runSyncExit(Serialization.normalizeSnapshotEffect(machine, snapshot)) + if (normalized._tag === "Failure") return undefined + const state = normalized.value + if (!Planner.isFinalState(machine, state)) { + return { machine, snapshot: { status: "active", state } } + } + const output = Effect.runSyncExit(Planner.getFinalOutputEffect(machine, state, Planner.InitialEvent)) + return output._tag === "Success" + ? { machine, snapshot: { status: "done", state, output: output.value } } + : undefined + } catch { + return undefined + } +} + +// Input mappers are synchronous. Child operations they capture only run inside +// a live runtime, so a planned read exposes an owner that refuses them. +const unavailableChildren: ChildOwner = { + spawn: () => Effect.die(new Error("Child operations are unavailable in a planned snapshot")), + sendTo: () => Effect.fail(new StoppedError()), + stop: () => Effect.void +} + +/** + * Plans the start of a fixed child descriptor invoked by the parent's planned + * configuration, mirroring the runtime's initial invocation order and entry + * context. `Option.none()` means the planned parent does not invoke it. + */ +export const planInvokedChild = ( + parent: PlannedSnapshot, + descriptor: ChildMachine.Any +): Option.Option | undefined => { + if (parent.snapshot.status !== "active") return Option.none() + try { + const machine = parent.machine + const configuration = Configuration.normalizeConfigurationSync(machine, parent.snapshot.state) + for (const path of Configuration.getInitialEntryPaths(machine, configuration)) { + const handlers = Configuration.getStateConfigByPath(machine, path) + for (const definition of InvocationDefinition.definitions(handlers?.invoke)) { + if ( + !("child" in definition) || definition.child.id !== descriptor.id || + definition.child.machine !== descriptor.machine + ) continue + const child = descriptor.machine + let input: unknown = undefined + if ("input" in definition) { + input = typeof definition.input === "function" + ? definition.input({ + ...(Configuration.getMachineReferences(configuration) ?? + Planner.resolveMachineReferences(machine, configuration)), + children: unavailableChildren, + state: configuration.values.get(path), + containingState: Configuration.getParentValue(machine, configuration, path), + ancestors: Configuration.getParentValues(machine, configuration, path), + event: Planner.InitialEvent + }) + : definition.input + } + const planned = planStart(child, child.input === undefined ? [] : [input]) + return planned === undefined ? undefined : Option.some(planned) + } + } + return Option.none() + } catch { + return undefined + } +} diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index 1f161cb..87fff34 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -150,7 +150,7 @@ export const withMachineReferences = ( return scoped } -const resolveMachineReferences = ( +export const resolveMachineReferences = ( machine: Machine.Any, configuration: ActiveConfiguration ): BehaviorMachineReferences => { diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachineServer.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachineServer.test.ts new file mode 100644 index 0000000..2d3dd3c --- /dev/null +++ b/packages/effect-machine/test/unstable/reactivity/AtomMachineServer.test.ts @@ -0,0 +1,288 @@ +import { assert, describe, it } from "@effect/vitest" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity" +import { Machine } from "../../../src/index.js" +import { AtomMachine } from "../../../src/unstable/reactivity/index.js" + +class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} +class Loaded extends Schema.TaggedClass("Loaded")("Loaded", {}) {} +class Editing extends Schema.TaggedClass("Editing")("Editing", { + email: Schema.String +}) {} +class Submitted extends Schema.TaggedClass("Submitted")("Submitted", {}) {} +class Searching extends Schema.TaggedClass("Searching")("Searching", { + query: Schema.String +}) {} +class Submit extends Schema.TaggedClass("Submit")("Submit", {}) {} + +class Tracker extends Context.Service +}>()("test/AtomMachineServer/Tracker") {} + +const makeCounter = () => { + const counter = { started: 0 } + const machine = Machine.make({ + root: Machine.state({ states: { Loading, Loaded } }), + events: Machine.eventsFromSchemas(), + effects: { + load: Effect.sync(() => { + counter.started++ + }).pipe(Effect.andThen(Effect.never)) + } + }).handle({ + initial: { target: "Loading", decoded: true, data: new Loading({}) }, + states: { + Loading: { invoke: { src: "load" } }, + Loaded: {} + } + }) + return { counter, machine } +} + +const SearchMachine = Machine.make({ + root: Machine.state({ states: { Searching } }), + events: Machine.eventsFromSchemas(), + input: Schema.String +}).handle({ + initial: { + target: "Searching", + decoded: true, + data: ({ input }) => new Searching({ query: input }) + }, + states: { Searching: {} } +}) +const SearchChild = Machine.child("search", SearchMachine) +const OtherSearchChild = Machine.child("other-search", SearchMachine) + +const makeForm = () => { + const counter = { started: 0 } + const machine = Machine.make({ + root: Machine.state({ states: { Editing, Submitted } }), + events: Machine.eventsFromSchemas(Submit), + input: Schema.String, + children: { search: SearchChild }, + effects: { + track: Effect.sync(() => { + counter.started++ + }).pipe(Effect.andThen(Effect.never)) + } + }).handle({ + initial: { + target: "Editing", + decoded: true, + data: ({ input }) => new Editing({ email: input }) + }, + states: { + Editing: { + invoke: [ + { src: "track" }, + { src: "search", input: ({ state }) => state.email } + ], + on: { + Submit: { target: "Submitted", decoded: true, data: () => new Submitted({}) } + } + }, + Submitted: {} + } + }) + return { counter, machine } +} + +const rootState = (state: State) => ({ path: "" as const, value: undefined, state }) + +const assertPlanned = (result: AsyncResult.AsyncResult, expected: A) => { + assert(AsyncResult.isSuccess(result)) + assert.strictEqual(result.waiting, true) + assert.deepStrictEqual(result.value, expected) +} + +const serverRead = (atom: Atom.Atom, registry: AtomRegistry.AtomRegistry): A => + Atom.getServerValue(atom, registry) + +describe("AtomMachine server values", () => { + it("does not start a machine on a server read", () => { + const { counter, machine } = makeCounter() + const registry = AtomRegistry.make() + assertPlanned( + serverRead(AtomMachine.make(machine).result, registry), + rootState({ path: "Loading", value: new Loading({}) }) + ) + assert.strictEqual(counter.started, 0) + registry.dispose() + }) + + it("serves the planned startup snapshot from every bridge atom", () => { + const { counter, machine } = makeForm() + const registry = AtomRegistry.make() + const bridge = AtomMachine.make(machine, "ada@example.com") + const editing = new Editing({ email: "ada@example.com" }) + const editingNode = { path: "Editing", value: editing } as const + const state = rootState(editingNode) + + assert.deepStrictEqual(serverRead(bridge.ref, registry), AsyncResult.initial(true)) + assertPlanned(serverRead(bridge.snapshot, registry), { status: "active", state }) + assertPlanned(serverRead(bridge.result, registry), state) + assertPlanned(serverRead(AtomMachine.select(bridge, "Editing"), registry), Option.some(editing)) + assertPlanned(serverRead(AtomMachine.select(bridge, "Submitted"), registry), Option.none()) + assertPlanned(serverRead(AtomMachine.selectSnapshot(bridge, "Editing"), registry), Option.some(editingNode)) + assertPlanned(serverRead(AtomMachine.matches(bridge, "Editing"), registry), true) + assertPlanned(serverRead(AtomMachine.can(new Submit({}))(bridge), registry), true) + assertPlanned(serverRead(AtomMachine.can(Atom.make(new Submit({})))(bridge), registry), true) + assert(AsyncResult.isInitial(serverRead(bridge.send, registry))) + assert(AsyncResult.isInitial(serverRead(bridge.stop, registry))) + + const search = bridge.child(SearchChild) + const searching = new Searching({ query: "ada@example.com" }) + const searchingNode = { path: "Searching", value: searching } as const + const childState = rootState(searchingNode) + assert.deepStrictEqual(serverRead(search.ref, registry), AsyncResult.initial(true)) + assertPlanned(serverRead(search.snapshot, registry), Option.some({ status: "active", state: childState })) + assertPlanned(serverRead(search.result, registry), Option.some(childState)) + assertPlanned(serverRead(AtomMachine.selectChild(search, "Searching"), registry), Option.some(searching)) + assertPlanned( + serverRead(AtomMachine.selectSnapshotChild(search, "Searching"), registry), + Option.some(searchingNode) + ) + assertPlanned(serverRead(AtomMachine.matchesChild(search, "Searching"), registry), true) + assert(AsyncResult.isInitial(serverRead(search.send, registry))) + assert(AsyncResult.isInitial(serverRead(search.stop, registry))) + + const other = bridge.child(OtherSearchChild) + assertPlanned(serverRead(other.result, registry), Option.none()) + assertPlanned(serverRead(AtomMachine.matchesChild(other, "Searching"), registry), false) + assertPlanned(serverRead(search.child(SearchChild).result, registry), Option.none()) + + assert.strictEqual(counter.started, 0) + registry.dispose() + }) + + it("serves planned values from family projections", () => { + const { counter, machine } = makeForm() + const registry = AtomRegistry.make() + const forms = AtomMachine.family(machine, { + atoms: { + editing: AtomMachine.select("Editing"), + result: (form) => form.result, + send: (form) => form.send + } + }) + assertPlanned( + serverRead(forms.editing("grace@example.com"), registry), + Option.some(new Editing({ email: "grace@example.com" })) + ) + assertPlanned( + serverRead(forms.result("grace@example.com"), registry), + rootState({ path: "Editing", value: new Editing({ email: "grace@example.com" }) }) + ) + assert(AsyncResult.isInitial(serverRead(forms.send("grace@example.com"), registry))) + + const parent = AtomMachine.make(machine, "linus@example.com") + const searches = AtomMachine.familyChild(parent, { + child: (id: string) => Machine.child(id, SearchMachine), + atoms: { searching: AtomMachine.selectChild("Searching") } + }) + assertPlanned( + serverRead(searches.searching("search"), registry), + Option.some(new Searching({ query: "linus@example.com" })) + ) + assertPlanned(serverRead(searches.searching("spawned"), registry), Option.none()) + assert.strictEqual(counter.started, 0) + registry.dispose() + }) + + it("serves the planned state of a bound machine without building its layer", () => { + let built = 0 + let tracked = 0 + const layer = Layer.effect( + Tracker, + Effect.sync(() => { + built++ + return Tracker.of({ track: Effect.sync(() => tracked++) }) + }) + ) + const machine = Machine.make({ + root: Machine.state({ states: { Loading, Loaded } }), + events: Machine.eventsFromSchemas(), + effects: { + load: Effect.gen(function*() { + const tracker = yield* Tracker + yield* tracker.track + }) + } + }).handle({ + initial: { target: "Loading", decoded: true, data: new Loading({}) }, + states: { + Loading: { invoke: { src: "load", onDone: { target: "Loaded", decoded: true, data: () => new Loaded({}) } } }, + Loaded: {} + } + }) + const registry = AtomRegistry.make() + const bridge = AtomMachine.bind(Atom.runtime(layer)).make(machine) + assertPlanned(serverRead(bridge.result, registry), rootState({ path: "Loading", value: new Loading({}) })) + assert.strictEqual(built, 0) + assert.strictEqual(tracked, 0) + registry.dispose() + }) + + it("serves Initial when the startup snapshot cannot be planned", () => { + const registry = AtomRegistry.make() + let started = 0 + const machine = Machine.make({ + root: Machine.state({ states: { Loading } }), + events: Machine.eventsFromSchemas(Submit) + }).handle({ + initial: { + target: "Loading", + decoded: true, + data: () => { + started++ + throw new Error("unplannable") + } + }, + states: { Loading: {} } + }) + const bridge = AtomMachine.make(machine) + assert.deepStrictEqual(serverRead(bridge.snapshot, registry), AsyncResult.initial(true)) + assert.deepStrictEqual(serverRead(bridge.result, registry), AsyncResult.initial(true)) + assert.deepStrictEqual(serverRead(AtomMachine.select(bridge, "Loading"), registry), AsyncResult.initial(true)) + assert.deepStrictEqual(serverRead(AtomMachine.can(new Submit({}))(bridge), registry), AsyncResult.initial(true)) + assert.deepStrictEqual(serverRead(bridge.child(SearchChild).result, registry), AsyncResult.initial(true)) + // The plan is computed once per bridge. + assert.strictEqual(started, 1) + registry.dispose() + }) + + it("serves the resumed snapshot of a resumed bridge", () => { + const { counter, machine } = makeForm() + const registry = AtomRegistry.make() + const bridge = AtomMachine.resume(machine, { + path: "" as const, + value: undefined, + state: { path: "Submitted" as const, value: new Submitted({}) } + }) + assertPlanned(serverRead(bridge.result, registry), rootState({ path: "Submitted", value: new Submitted({}) })) + assertPlanned(serverRead(AtomMachine.can(new Submit({}))(bridge), registry), false) + assertPlanned(serverRead(bridge.child(SearchChild).result, registry), Option.none()) + assert.strictEqual(counter.started, 0) + registry.dispose() + }) + + it.effect("starts exactly once on a client mount after a server read", () => + Effect.gen(function*() { + const { counter, machine } = makeCounter() + const registry = AtomRegistry.make() + const bridge = AtomMachine.make(machine) + serverRead(bridge.result, registry) + serverRead(AtomMachine.select(bridge, "Loading"), registry) + assert.strictEqual(counter.started, 0) + const release = registry.mount(bridge.result) + const state = yield* AtomRegistry.getResult(registry, bridge.result) + assert.deepStrictEqual(state, rootState({ path: "Loading", value: new Loading({}) })) + yield* Effect.yieldNow + assert.strictEqual(counter.started, 1) + serverRead(bridge.result, registry) + assert.strictEqual(counter.started, 1) + release() + registry.dispose() + })) +})