Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/machine-context-options.md
Original file line number Diff line number Diff line change
@@ -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<Navigation> }) =>
AtomMachine.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))).make(OnboardingMachine)
)

<Onboarding.Provider layer={navigationLayer(router)} />
```
14 changes: 8 additions & 6 deletions packages/effect-machine-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -52,19 +52,21 @@ active:
</MachineState>
```

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<Navigation>) =>
AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer)))
({ input, layer }: { input: SignInInput; layer: Layer.Layer<Navigation> }) =>
AtomMachine.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer)))
.make(SignInMachine, input)
)

Expand Down
64 changes: 46 additions & 18 deletions packages/effect-machine-react/src/MachineAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,27 +139,49 @@ export interface MachineContext<Args extends MachineContext.Args, A extends Atom
*/
export declare namespace MachineContext {
/**
* Parameters a bridge factory may declare: startup input, then a per-owner
* capability layer. Declare the input as `void` for a layer without input.
* Values a Provider passes to its bridge factory: the machine's startup
* `input` and a per-owner capability `layer`. A factory declares only the
* values it uses.
*
* @category models
* @since 0.44.0
*/
export interface Options {
readonly input?: unknown
readonly layer?: Layer.Layer<any, any, any>
}

/**
* 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<any, any, any>]
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<Args extends MachineContext.Args> =
& { 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<keyof Options, keyof O>]?: 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 MachineContext.Args> = Args extends [infer O]
? [Exclude<keyof O, keyof Options>] extends [never] ? unknown
: { readonly "createMachineContext options accept only input and layer": Exclude<keyof O, keyof Options> }
: unknown
}

/**
Expand All @@ -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
Expand All @@ -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<Navigation>) =>
* AtomMachine.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer)))
* .make(SignInMachine, input)
* ({ input, layer }: { input: SignInInput; layer: Layer.Layer<Navigation> }) =>
* AtomMachine.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))).make(SignInMachine, input)
* )
*
* <SignIn.Provider input={input} layer={navigationLayer(router)}>...</SignIn.Provider>
Expand All @@ -195,5 +223,5 @@ export declare namespace MachineContext {
* @since 0.32.0
*/
export const createMachineContext: <Args extends MachineContext.Args, A extends AtomMachine.MachineAtom.Any>(
create: (...args: Args) => A
create: ((...args: Args) => A) & MachineContext.EnsureOptions<NoInfer<Args>>
) => MachineContext<Args, A> = internal.createMachineContext
14 changes: 9 additions & 5 deletions packages/effect-machine-react/src/internal/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,15 @@ export const createMachineContext = <Args extends MachineContext.Args, A extends
readonly children?: React.ReactNode
}): React.ReactNode => {
// 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 => {
Expand Down
8 changes: 4 additions & 4 deletions packages/effect-machine-react/test/RootContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -168,8 +168,8 @@ const leaving = Machine.make({
Left: {}
}
})
const Leave = createMachineContext((input: string, navigation: Layer.Layer<Navigation>) =>
AtomMachine.bind(Atom.runtime(navigation)).make(leaving, input)
const Leave = createMachineContext(({ input, layer }: { input: string; layer: Layer.Layer<Navigation> }) =>
AtomMachine.bind(Atom.runtime(layer)).make(leaving, input)
)
it("provides each owner's capability layer to its machine", async () => {
const registry = AtomRegistry.make()
Expand Down Expand Up @@ -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<Navigation>) =>
const NoInput = createMachineContext(({ layer }: { layer: Layer.Layer<Navigation> }) =>
AtomMachine.bind(Atom.runtime(layer)).make(leaving, "/home")
)
let bridge: ReturnType<typeof NoInput.useMachine> | undefined
Expand Down
4 changes: 2 additions & 2 deletions packages/effect-machine-react/test/ServerRendering.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Onboarding.useMachine> | undefined
function EmailField() {
bridge = Onboarding.useMachine()
Expand Down Expand Up @@ -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<React.PropsWithChildren, { failed: boolean }> {
state = { failed: false }
Expand Down
19 changes: 13 additions & 6 deletions packages/effect-machine-react/typetest/MachineAtom.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof InputContext.Provider>
type NoInputProps = React.ComponentProps<typeof NoInputContext.Provider>
Expand Down Expand Up @@ -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<Navigation>) =>
AtomMachine.bind(Atom.runtime(navigation)).make(Leaving, input)
const LayerContext = createMachineContext(({ input, layer }: { input: string; layer: Layer.Layer<Navigation> }) =>
AtomMachine.bind(Atom.runtime(layer)).make(Leaving, input)
)
type LayerProps = React.ComponentProps<typeof LayerContext.Provider>
expect<LayerProps["layer"]>().type.toBe<Layer.Layer<Navigation>>()
Expand All @@ -164,13 +164,20 @@ expect<{ input: string; layer: Layer.Layer<Clock> }>().type.not.toBeAssignableTo
expect<{ input: string; layer: Layer.Layer<Navigation> }>().type.toBeAssignableTo<LayerProps>()
expect<{ layer: Layer.Layer<never> }>().type.not.toBeAssignableTo<InputProps>()
expect<{ layer: Layer.Layer<never> }>().type.not.toBeAssignableTo<NoInputProps>()
const LayerOnlyContext = createMachineContext((_: void, navigation: Layer.Layer<Navigation>) =>
AtomMachine.bind(Atom.runtime(navigation)).make(Leaving, "/")
const LayerOnlyContext = createMachineContext(({ layer }: { layer: Layer.Layer<Navigation> }) =>
AtomMachine.bind(Atom.runtime(layer)).make(Leaving, "/")
)
type LayerOnlyProps = React.ComponentProps<typeof LayerOnlyContext.Provider>
expect<{ layer: Layer.Layer<Navigation> }>().type.toBeAssignableTo<LayerOnlyProps>()
expect<{ input: string; layer: Layer.Layer<Navigation> }>().type.not.toBeAssignableTo<LayerOnlyProps>()
expect<{}>().type.not.toBeAssignableTo<LayerOnlyProps>()
expect(
(_input: string, clock: Layer.Layer<Clock>) => AtomMachine.bind(Atom.runtime(clock)).make(Leaving, _input)
({ input, layer }: { input: string; layer: Layer.Layer<Clock> }) =>
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)
)
33 changes: 20 additions & 13 deletions packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -148,9 +153,9 @@ const navigationLayer = (router: Router) =>
})

export const Onboarding = createMachineContext(
(_: void, navigation: Layer.Layer<Navigation>) =>
({ layer }: { layer: Layer.Layer<Navigation> }) =>
AtomMachine
.bind(Atom.runtime(Layer.provideMerge(navigation, AppLayer)))
.bind(Atom.runtime(Layer.provideMerge(layer, AppLayer)))
.make(OnboardingMachine)
)

Expand All @@ -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<Navigation>)`. 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<Navigation> })`.
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

Expand Down
Loading