From c86d57ac40f9439d0b3e3cd3adca143f3fa47f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:06:29 +0000 Subject: [PATCH] Take one options object in createMachineContext factories A factory declares the options it uses, input and layer, and each one is a Provider prop of the same name and type. A machine that needs only a capability layer no longer declares a placeholder input parameter. Options other than input and layer are rejected, and a factory without parameters, such as AtomMachine.factory for a machine without input, is still passed directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H9xMXPkpeqmvAbWNGY7R55 --- .changeset/machine-context-options.md | 17 +++++ packages/effect-machine-react/README.md | 14 ++-- .../effect-machine-react/src/MachineAtom.ts | 64 +++++++++++++------ .../src/internal/react.ts | 14 ++-- .../test/RootContext.test.tsx | 8 +-- .../test/ServerRendering.test.tsx | 4 +- .../typetest/MachineAtom.tst.ts | 19 ++++-- .../effect-machine/docs/effect-atom-react.md | 33 ++++++---- 8 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 .changeset/machine-context-options.md diff --git a/.changeset/machine-context-options.md b/.changeset/machine-context-options.md new file mode 100644 index 00000000..4be12647 --- /dev/null +++ b/.changeset/machine-context-options.md @@ -0,0 +1,17 @@ +--- +"@typeonce/effect-machine-react": minor +--- + +`createMachineContext` factories now take one options object instead of positional parameters. Each option the factory declares, `input` or `layer`, is a Provider prop of the same name and type, so a machine that needs only a capability layer no longer declares a placeholder input. Options other than `input` and `layer` are rejected. + +A factory for a machine without input can still be passed directly, as in `createMachineContext(AtomMachine.factory(Machine))`. Factories that take input or a layer destructure them: + +```tsx +const Profile = createMachineContext(({ input }: { input: ProfileInput }) => AtomMachine.make(ProfileMachine, input)) + +const Onboarding = createMachineContext(({ layer }: { layer: Layer.Layer }) => + AtomMachine.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))).make(OnboardingMachine) +) + + +``` diff --git a/packages/effect-machine-react/README.md b/packages/effect-machine-react/README.md index e076df37..519e5155 100644 --- a/packages/effect-machine-react/README.md +++ b/packages/effect-machine-react/README.md @@ -8,7 +8,7 @@ import { createMachineContext, MachineState } from "@typeonce/effect-machine-rea import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { AuthMachine } from "./auth-machine" -const Auth = createMachineContext(AtomMachine.factory(AuthMachine)) +const Auth = createMachineContext(({ input }: { input: AuthInput }) => AtomMachine.make(AuthMachine, input)) function AuthRoute({ input }: Props) { return ( @@ -52,19 +52,21 @@ active: ``` +The factory takes one options object, and each option it declares is a +Provider prop of the same name and type. A machine without input can pass +`AtomMachine.factory(Machine)` directly; its Provider takes no `input` prop. Input is startup-only. Send an event to update a running workflow, or change the -Provider's React `key` to start a new one. Machines without input have a Provider -without an `input` prop. +Provider's React `key` to start a new one. Per-owner capabilities, such as a router-bound navigation service, are layers, -never input. Declare a second, `Layer`-typed factory parameter, and the Provider +never input. Declare a `layer` option typed as a `Layer`, and the Provider requires a `layer` prop of that type. Like input, the layer is read once when the Provider mounts: ```tsx const SignIn = createMachineContext( - (input: SignInInput, navigation: Layer.Layer) => - AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer))) + ({ input, layer }: { input: SignInInput; layer: Layer.Layer }) => + AtomMachine.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))) .make(SignInMachine, input) ) diff --git a/packages/effect-machine-react/src/MachineAtom.ts b/packages/effect-machine-react/src/MachineAtom.ts index da1b39f7..75c34d03 100644 --- a/packages/effect-machine-react/src/MachineAtom.ts +++ b/packages/effect-machine-react/src/MachineAtom.ts @@ -139,27 +139,49 @@ export interface MachineContext + } + + /** + * Parameters a bridge factory may declare: none, or one `Options` object. * * @category utility types * @since 0.43.0 */ - export type Args = [] | [input: unknown] | [input: unknown, layer: Layer.Layer] + export type Args = [] | [options: Options] /** - * Provider props: `input` is required when the factory declares input and - * `layer` when it declares a layer. Both are rejected otherwise. + * Provider props mirror the factory's options: each declared option is a + * required prop of the same type, and undeclared options are rejected. * * @category utility types * @since 0.43.0 */ export type ProviderProps = & { readonly children?: React.ReactNode } - & (Args extends [] ? { readonly input?: never } - : [Args[0]] extends [void] ? { readonly input?: never } - : { readonly input: Args[0] }) - & (Args extends [unknown, infer L] ? { readonly layer: L } : { readonly layer?: never }) + & (Args extends [infer O] ? O & { readonly [K in Exclude]?: never } + : { readonly [K in keyof Options]?: never }) + + /** + * Rejects factory options other than `input` and `layer`, so a capability + * cannot be passed under another name. + * + * @category utility types + * @since 0.44.0 + */ + export type EnsureOptions = Args extends [infer O] + ? [Exclude] extends [never] ? unknown + : { readonly "createMachineContext options accept only input and layer": Exclude } + : unknown } /** @@ -169,11 +191,12 @@ export declare namespace MachineContext { * * **Details** * - * The factory's first parameter is the Provider's `input` prop. A second - * parameter typed as a `Layer` is the Provider's `layer` prop: a per-owner - * capability, such as a router-bound service, that the factory provides to the - * machine through `AtomMachine.bind(Atom.runtime(...))`. Binding rejects a - * machine whose services the runtime does not provide. + * The factory takes no parameters or one options object. Each option it + * declares is a Provider prop of the same name and type: `input` is the + * machine's startup input, and `layer` is a per-owner capability, such as a + * router-bound service, that the factory provides to the machine through + * `AtomMachine.bind(Atom.runtime(...))`. Binding rejects a machine whose + * services the runtime does not provide. * * Input and layer are read once, when the Provider mounts. Send an event to * update a running workflow, or change the Provider's React `key` to create a @@ -182,10 +205,15 @@ export declare namespace MachineContext { * **Example** * * ```tsx + * const Auth = createMachineContext(AtomMachine.factory(AuthMachine)) + * + * const Profile = createMachineContext(({ input }: { input: ProfileInput }) => + * AtomMachine.make(ProfileMachine, input) + * ) + * * const SignIn = createMachineContext( - * (input: SignInInput, navigation: Layer.Layer) => - * AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer))) - * .make(SignInMachine, input) + * ({ input, layer }: { input: SignInInput; layer: Layer.Layer }) => + * AtomMachine.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))).make(SignInMachine, input) * ) * * ... @@ -195,5 +223,5 @@ export declare namespace MachineContext { * @since 0.32.0 */ export const createMachineContext: ( - create: (...args: Args) => A + create: ((...args: Args) => A) & MachineContext.EnsureOptions> ) => MachineContext = internal.createMachineContext diff --git a/packages/effect-machine-react/src/internal/react.ts b/packages/effect-machine-react/src/internal/react.ts index 66db0754..70ccdd97 100644 --- a/packages/effect-machine-react/src/internal/react.ts +++ b/packages/effect-machine-react/src/internal/react.ts @@ -83,11 +83,15 @@ export const createMachineContext = { // Input and layer belong to this committed owner. A new React key creates a new owner. - const machine = useMachineAtom(() => - create( - ...("layer" in props ? [props.input, props.layer] : "input" in props ? [props.input] : []) as Args - ) - ) + const machine = useMachineAtom(() => { + const options: { input?: unknown; layer?: unknown } = {} + if ("input" in props) options.input = props.input + if ("layer" in props) options.layer = props.layer + // A factory without parameters, such as `AtomMachine.factory(machine)` + // for a machine without input, is called without an options object. + const declaresOptions = create.length > 0 || "input" in options || "layer" in options + return create(...(declaresOptions ? [options] : []) as Args) + }) return React.createElement(Context.Provider, { value: machine }, props.children) } const useMachine = (): A => { diff --git a/packages/effect-machine-react/test/RootContext.test.tsx b/packages/effect-machine-react/test/RootContext.test.tsx index c3e07e1b..754d55be 100644 --- a/packages/effect-machine-react/test/RootContext.test.tsx +++ b/packages/effect-machine-react/test/RootContext.test.tsx @@ -25,7 +25,7 @@ const counter = Machine.make({ Closed: {} } }) -const Counter = createMachineContext(AtomMachine.factory(counter)) +const Counter = createMachineContext(({ input }: { input: number }) => AtomMachine.make(counter, input)) afterEach(cleanup) it("keeps a provider's input and bridge stable until its React key changes", async () => { const registry = AtomRegistry.make({ defaultIdleTTL: 1 }) @@ -168,8 +168,8 @@ const leaving = Machine.make({ Left: {} } }) -const Leave = createMachineContext((input: string, navigation: Layer.Layer) => - AtomMachine.bind(Atom.runtime(navigation)).make(leaving, input) +const Leave = createMachineContext(({ input, layer }: { input: string; layer: Layer.Layer }) => + AtomMachine.bind(Atom.runtime(layer)).make(leaving, input) ) it("provides each owner's capability layer to its machine", async () => { const registry = AtomRegistry.make() @@ -208,7 +208,7 @@ it("reads the capability layer once when the owner mounts", async () => { Layer.succeed(Navigation, { push: (href) => Effect.sync(() => pushed.push(`${router}:${href}`)) }) - const NoInput = createMachineContext((_: void, layer: Layer.Layer) => + const NoInput = createMachineContext(({ layer }: { layer: Layer.Layer }) => AtomMachine.bind(Atom.runtime(layer)).make(leaving, "/home") ) let bridge: ReturnType | undefined diff --git a/packages/effect-machine-react/test/ServerRendering.test.tsx b/packages/effect-machine-react/test/ServerRendering.test.tsx index 51015bb4..8f7854e1 100644 --- a/packages/effect-machine-react/test/ServerRendering.test.tsx +++ b/packages/effect-machine-react/test/ServerRendering.test.tsx @@ -93,7 +93,7 @@ const hydrate = async (container: HTMLElement, element: React.ReactNode) => { 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)) + const Onboarding = createMachineContext(({ input }: { input: string }) => AtomMachine.make(machine, input)) let bridge: ReturnType | undefined function EmailField() { bridge = Onboarding.useMachine() @@ -237,7 +237,7 @@ it("renders pending until a client-only machine has a snapshot", async () => { 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)) + const Onboarding = createMachineContext(({ input }: { input: string }) => AtomMachine.make(machine, input)) let caught: unknown class Boundary extends React.Component { state = { failed: false } diff --git a/packages/effect-machine-react/typetest/MachineAtom.tst.ts b/packages/effect-machine-react/typetest/MachineAtom.tst.ts index 22339988..3c72a89d 100644 --- a/packages/effect-machine-react/typetest/MachineAtom.tst.ts +++ b/packages/effect-machine-react/typetest/MachineAtom.tst.ts @@ -46,7 +46,7 @@ const withInput = Machine.make({ root: ({ input }) => ({ count: input }), states: { Editing: {}, Closed: {} } }) -const InputContext = createMachineContext(AtomMachine.factory(withInput)) +const InputContext = createMachineContext(({ input }: { input: number }) => AtomMachine.make(withInput, input)) const NoInputContext = createMachineContext(AtomMachine.factory(machine)) type InputProps = React.ComponentProps type NoInputProps = React.ComponentProps @@ -153,8 +153,8 @@ const Leaving = Machine.make({ initial: { target: "Leaving" }, states: { Leaving: { invoke: { src: "navigate", onDone: { target: "Done" } } }, Done: {} } }) -const LayerContext = createMachineContext((input: string, navigation: Layer.Layer) => - AtomMachine.bind(Atom.runtime(navigation)).make(Leaving, input) +const LayerContext = createMachineContext(({ input, layer }: { input: string; layer: Layer.Layer }) => + AtomMachine.bind(Atom.runtime(layer)).make(Leaving, input) ) type LayerProps = React.ComponentProps expect().type.toBe>() @@ -164,13 +164,20 @@ expect<{ input: string; layer: Layer.Layer }>().type.not.toBeAssignableTo expect<{ input: string; layer: Layer.Layer }>().type.toBeAssignableTo() expect<{ layer: Layer.Layer }>().type.not.toBeAssignableTo() expect<{ layer: Layer.Layer }>().type.not.toBeAssignableTo() -const LayerOnlyContext = createMachineContext((_: void, navigation: Layer.Layer) => - AtomMachine.bind(Atom.runtime(navigation)).make(Leaving, "/") +const LayerOnlyContext = createMachineContext(({ layer }: { layer: Layer.Layer }) => + AtomMachine.bind(Atom.runtime(layer)).make(Leaving, "/") ) type LayerOnlyProps = React.ComponentProps expect<{ layer: Layer.Layer }>().type.toBeAssignableTo() expect<{ input: string; layer: Layer.Layer }>().type.not.toBeAssignableTo() expect<{}>().type.not.toBeAssignableTo() expect( - (_input: string, clock: Layer.Layer) => AtomMachine.bind(Atom.runtime(clock)).make(Leaving, _input) + ({ input, layer }: { input: string; layer: Layer.Layer }) => + AtomMachine.bind(Atom.runtime(layer)).make(Leaving, input) ).type.toRaiseError() +// A factory that takes input positionally would receive the options object. +expect(createMachineContext).type.not.toBeCallableWith(AtomMachine.factory(withInput)) +// Capabilities travel as `layer`, never under another option name. +expect(createMachineContext).type.not.toBeCallableWith( + ({ input }: { input: number; router: string }) => AtomMachine.make(withInput, input) +) diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md index a17f6141..72bfacc4 100644 --- a/packages/effect-machine/docs/effect-atom-react.md +++ b/packages/effect-machine/docs/effect-atom-react.md @@ -47,7 +47,9 @@ import { createMachineContext } from "@typeonce/effect-machine-react" import { AuthMachine } from "../machines/auth-machine" import { MachineAtoms } from "../lib/atom-runtime" -export const Auth = createMachineContext(MachineAtoms.factory(AuthMachine)) +export const Auth = createMachineContext(({ input }: { input: AuthInput }) => + MachineAtoms.make(AuthMachine, input) +) export const useAuthMachine = Auth.useMachine function AuthRoute({ input, attemptId }: Props) { @@ -59,6 +61,9 @@ function AuthRoute({ input, attemptId }: Props) { } ``` +The factory takes one options object, and each option it declares is a +Provider prop of the same name and type. A machine without input can pass +`MachineAtoms.factory(Machine)` directly, and its Provider takes no `input`. Input is startup-only. Send events to update the existing workflow; change the 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 @@ -135,9 +140,9 @@ once. `Atom.runtime` builds layers through the registry's memo map: the shared own capability layer. Use `Layer.provideMerge(ownerLayer, AppLayer)` when the owner layer itself needs application services. -To share the owner-bound machine with a subtree, declare the capability as the -second, `Layer`-typed parameter of a `createMachineContext` factory. The first -parameter is the startup input; `OnboardingMachine` has none, so it is `void`: +To share the owner-bound machine with a subtree, declare the capability as a +`layer` option of a `createMachineContext` factory. `OnboardingMachine` has no +input, so the factory declares only `layer`: ```tsx import { createMachineContext } from "@typeonce/effect-machine-react" @@ -148,9 +153,9 @@ const navigationLayer = (router: Router) => }) export const Onboarding = createMachineContext( - (_: void, navigation: Layer.Layer) => + ({ layer }: { layer: Layer.Layer }) => AtomMachine - .bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer))) + .bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))) .make(OnboardingMachine) ) @@ -164,13 +169,15 @@ function OnboardingRoute() { ``` The Provider requires a `layer` prop of exactly the declared `Layer` type, and -an `input` prop when the first parameter is the machine's input, as in -`(input: SignInInput, navigation: Layer.Layer)`. A factory without -a layer parameter has a Provider without a `layer` prop. Like `input`, the -layer is read once, when the Provider mounts: a new router instance does not -rebuild the running machine. Change the Provider's React `key` to create a new -owner with a new layer. Binding still checks the machine's requirements, so a -layer type that omits `Navigation` is a compile error. +an `input` prop when the factory also declares input, as in +`({ input, layer }: { input: SignInInput; layer: Layer.Layer })`. +Options other than `input` and `layer` are rejected, so a capability cannot +travel under another name. A factory without a `layer` option has a Provider +without a `layer` prop. Like `input`, the layer is read once, when the Provider +mounts: a new router instance does not rebuild the running machine. Change the +Provider's React `key` to create a new owner with a new layer. Binding still +checks the machine's requirements, so a layer type that omits `Navigation` is a +compile error. ## Render state-owned data