Require input on transitions targeting states with an input schema - #5601
Require input on transitions targeting states with an input schema#5601asfktz wants to merge 14 commits into
Conversation
Type-level enforcement: transitions targeting a state that declares schemas.input now require the input property. Covers on, always, after, onTimeout, onDone, onError, invoke handlers, function-syntax returns, and object-form initial. - StateTransitionResult: input conditionally required in the sibling branch - StateNodeConfigWithNestedInput: route after/onError/onTimeout through the transition type so they carry (and require) input - InitialTransitionWithInput: input conditionally required Out of scope (input stays optional): #id, .child, array/parallel targets, and bare-string initial shorthand.
Runtime backstop for the required-input feature: when input is provided on a transition whose target is not actually being (re)entered (classic case: a self-transition without reenter: true), the input is now dropped instead of silently stored, and a dev-only console.warn explains why and how to fix it (add reenter: true). Guards the stateInputMap storage loop in microstep with statesToEnter.has(targetNode), keying off the real computed entry set rather than a hand-rolled predicate, so default-entry descendants of compound and parallel targets are handled correctly. Complements statelyai#5597: prevents a non-entering self-transition from clobbering _stateInputs, which the timeout/onTimeout/after/event handlers now read.
A self-transition to a state that declares `schemas.input` now requires `input` only when the state is actually re-entered (`reenter: true`, or a dynamic `reenter`). Without `reenter` the target is not re-entered and any provided input is dropped at runtime, so `input` is optional there. Cross- target and no-schema transitions are unchanged. Threads the source state's own sibling key as `TSelfKey` from the `StatesWithInput` mapper down to `StateTransitionResult`, where a new `TransitionInputRequirement` helper discriminates the `input` requirement on `reenter` for the self key. This removes a contradiction with the runtime backstop, which previously warned that the very input the type layer required was being ignored.
Removes the TSelfKey type parameter and the self-transition-without-reenter detection it enabled. TransitionInputRequirement now applies one uniform rule: a transition to a state declaring schemas.input requires `input`, whether the target is the source state itself or a different sibling. This deletes ~24 pass-through sites that threaded TSelfKey from StatesWithInput down to StateTransitionResult. The cost is that a self-transition without reenter now requires `input` that the runtime still silently drops (the target is not re-entered); add `reenter: true` to actually apply it.
Merge three overlapping tests into one that threads input behavior through a single machine via count arithmetic: internal self-transitions keep the resolved input, reentering self-transitions replace it, and transition handlers read the current value throughout.
🦋 Changeset detectedLatest commit: b8b203b The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Quick thoughts:
|
How about throwing a warning at runtime that the input is ignored for internal transitions and hint the user to add
For IDs? Yes, I agree.
Makes sense. The alternative would be introducing a new transition syntax like (and very roughly): {
target: 'state',
regions: {
a: { input: {} },
b: { input: {} }
}
}However, that requires careful design to get right, and I agree it's better not to block on it (tbh, I've never been a fan of parallel states personally). Regarding transitioning to nested states ( But if we limit the input to top-level states only, this becomes unnecessary, of course. |
|
Thinking a little outside the box, we could provide one or more of:
And the TS blind spots (like self-transitions, parallel states maybe, states with IDs, nested states, etc.) could be handled with those (optionally) and hard-fail in runtime. @Andarist Would love your thoughts |
I think the challenge is that one setup can be used for many machine configurations. So in theory this is possible: // one setup
export const def = setup({
schemas: {
events: {
next: z.object({}),
},
},
states: {
stateA: {},
stateB: {
schemas: {
input: z.object({ propB: z.string() }),
},
},
stateC: {
schemas: {
input: z.object({ propC: z.string() }),
},
},
},
});
const stateA = def.createStateConfig("stateA", {
on: {
next: () => {
return {
target: "#id",
input: { ...? },
// Should the static analysis tool resolve
// input to { propB: string } or { propC: string }?
};
},
},
});
// multiple machine configurations
const machineA = def.createMachine({
initial: "idle",
states: {
stateA: stateA,
stateB: { id: "id" }, // 👈
stateC: {},
},
});
const machineB = def.createMachine({
initial: "idle",
states: {
stateA: stateA,
stateB: {},
stateC: { id: "id" }, // 👈
},
});Maybe the simplest solution would be to move the source of truth to For example, what if we allow specifying IDs in setup? export const def = setup({
schemas: {
events: {
next: z.object({}),
},
},
states: {
stateA: {},
stateB: {
id: "id",
schemas: {
input: z.object({ propB: z.string() }),
},
},
stateC: {
schemas: {
input: z.object({ propC: z.string() }),
},
},
},
});
const stateA = def.createStateConfig("stateA", {
on: {
next: () => ({
target: "#id",
input: { propB: "value" },
// Transition by ID inferred from setup — no ambiguity
}),
},
});
// multiple machine configurations
def.createMachine({
initial: "stateA",
states: {
stateA: stateA,
stateB: {
id: "id", // must match
},
stateC: {},
},
});
def.createMachine({
initial: "stateA",
states: {
stateA: stateA,
stateB: {}, // or omit (will default to the ID defined in setup)
stateC: {},
},
});
def.createMachine({
initial: "stateA",
states: {
stateA: stateA,
stateB: {},
stateC: { id: "id" }, // wrong state — type error
},
});
// typeless usage stays as-is
createMachine({
initial: "stateA",
states: {
stateA: {
on: {
next: () => ({
target: "#id",
input: { propB: "value" },
}),
},
},
stateB: { id: "id" },
stateC: {},
},
});We can do the same for parallel states by adding export const def = setup({
schemas: {
events: {
activate: z.object({}),
},
},
states: {
idle: {},
active: {
type: "parallel", // 👈
states: {
bold: {
states: { off: {}, on: {} },
},
italic: {
states: { off: {}, on: {} },
},
underline: {
states: { off: {}, on: {} },
},
},
},
},
});
def.createMachine({
initial: "idle",
states: {
idle: idle,
active: {
type: "parallel", // must match or omit
states: {
bold: {},
italic: {},
underline: {},
},
},
},
});Now, there are two cases I'm thinking about when transitioning to a parallel state with input:
const idle = def.createStateConfig("idle", {
on: {
activate: () => ({
target: "editing",
input: {
bold: { boldProp: "value" },
italic: { italicProp: "value" },
underline: { underlineProp: "value" },
},
}),
},
});
const idle = def.createStateConfig("idle", {
on: {
activate: () => ({
target: "editing",
input: {
prop: "value",
},
}),
},
});I think we should support only the second case (requiring input only on the parallel state itself), because it opens the door to a nice simplification. Here's what I mean: When a state in setup has
export const def = setup({
schemas: {
events: {
activate: z.object({}),
},
},
states: {
idle: {},
editing: {
type: "parallel", // 👈 by specifying the type, input schema is omitted from direct child states
schemas: {
input: z.object({ prop: z.string() }),
},
states: {
bold: {
schemas: {
input: {}, // omitted; receives the parent input
},
states: {
off: {
schemas: {
input: {}, // allowed
},
},
on: {
schemas: {
input: {}, // allowed
},
},
},
},
italic: {
states: { off: {}, on: {} },
},
underline: {
states: { off: {}, on: {} },
},
},
},
},
});
def.createMachine({
idle: {
on: {
next: () => ({
target: "editing",
input: { prop: "value" },
}),
},
},
editing: {
type: 'parallel',
entry: ({ input }) => input.prop,
states: {
bold: {
entry: ({ input }) => input.prop, // 👈 receives resolved parent input
},
italic: {
entry: ({ input }) => input.prop,
},
underline: {
entry: ({ input }) => input.prop,
},
},
},
});That way the whole problem dissolves — and we can also apply the same pattern to another problem: what if each parallel state region defines its own context schema and they're not compatible?
We can just omit |
|
@davidkpiano maybe it's worth merging the PR even if there are still some uncertainties around input typings for parallel states targets and by ID? |
Transitions that target a state declaring
schemas.inputnow require aninputproperty, enforced at the type level.This applies to
on,always,after,onTimeout,onDone,onError, invoke handlers, andinitial.Special Cases
Self-transitions with input
When a state transitions to itself without
reenter: true, it’s considered an internal transition.However, the transition type is not aware of the current state it’s transitioning from, so there is no way to tell that we shouldn’t require input for this case.
I experimented with extending the types, but ultimately decided not to add complexity for this rare case.
However, at runtime, I ensured
inputis ignored, so only external transitions resolve input.Unhandled cases / open questions
Targeting a state by
#idSince IDs are defined in
createMachine(notsetup), types can’t infer them by readingsetupalone.Unfortunately, this case is not covered.
Parallel states
How do you pass input to multiple regions in a parallel state?
Transition to nested child (
parent.child)What should happen if both the parent and child require input?
In that case, it may be worth constraining both at runtime and by types so entering a sub-state isn’t allowed if its parent requires input.