diff --git a/.changeset/tidy-choices-decode.md b/.changeset/tidy-choices-decode.md new file mode 100644 index 0000000..0cb6730 --- /dev/null +++ b/.changeset/tidy-choices-decode.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": patch +--- + +Fix initial choice resolvers so schema-backed `containingState` and `ancestors` are decoded before the choice runs. + +This makes `.from(...)` state construction behave the same for transient initial choices as it does for ordinary active-state entry. diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index f4c8678..9ca9eb7 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -1277,15 +1277,19 @@ function resolveChoiceTarget( if (choice === undefined || typeof choice.transition !== "function") { throw new Error(`Machine choice state "${node.path}" requires an implementation`) } + const provisionalValues = new Map(configuration.values) + for (const [path, value] of Object.entries(extracted.values)) { + const valueNode = getNode(machine, path) + if (valueNode.schema !== undefined) { + provisionalValues.set(path, decodeStateValueSync(machine, valueNode, value)) + } + } const provisional: ActiveConfiguration = { active: new Set([ ...configuration.active, ...Object.keys(extracted.values) ]), - values: new Map([ - ...configuration.values, - ...Object.entries(extracted.values) - ]), + values: provisionalValues, outputs: configuration.outputs, history: configuration.history, ...(configuration.machineReferences === undefined ? {} : { machineReferences: configuration.machineReferences }) diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index a466c66..4fa0842 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -879,6 +879,99 @@ describe("machine planner and runtime strategies", () => { assert.deepStrictEqual(results[1], results[0]) }) as Effect.Effect) + it.effect("matches initial choice invoke and retry lifecycles across managed runtimes", () => + Effect.gen(function*() { + class Flow extends Schema.TaggedClass("StrategyChoiceFlow")("StrategyChoiceFlow", { + authenticated: Schema.Boolean + }) {} + class Retry extends Schema.TaggedClass("StrategyChoiceRetry")("StrategyChoiceRetry", {}) {} + const states = Machine.states({ + Flow: { + schema: Flow, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Checking: {}, + Failed: {}, + Plans: {}, + MemberNavigating: {} + } + } + }) + const waitForPath = (ref: Machine.MachineRef, path: string) => + Effect.gen(function*() { + for (let index = 0; index < 100; index += 1) { + if ((yield* ref.state).state.path === path) return + yield* Effect.yieldNow + } + return assert.fail(`machine did not reach ${path}`) + }) + const results: Array = [] + + for (const strategy of ["generic", "compiled"] as const) { + let membershipAttempts = 0 + let navigationRuns = 0 + const machine = Machine.make({ + states: states.states, + events: Machine.events(Retry), + input: Schema.Struct({ authenticated: Schema.Boolean }), + initial: (to) => to.Flow.initial.resolve(({ input, target }) => target.from(input, (flow) => flow.Routing())) + }).handle({ + Flow: { + states: { + Routing: { + choice: (to) => + to.branches({ + authenticated: { target: to.local.Checking() }, + anonymous: { target: to.local.Plans() } + }).resolve(({ containingState, select }) => + containingState.authenticated ? select.authenticated.from() : select.anonymous.from() + ) + }, + Checking: { + invoke: (from) => + from.effect("membership", () => + Effect.suspend(() => { + membershipAttempts += 1 + return membershipAttempts === 1 ? Effect.fail("offline") : Effect.succeed("active") + })).onDone((to) => to.local.MemberNavigating()).onFailure((to) => to.local.Failed()) + }, + Failed: { + on: { + StrategyChoiceRetry: (to) => to.local.Checking() + } + }, + MemberNavigating: { + invoke: (from) => + from.effect("navigate", () => + Effect.sync(() => { + navigationRuns += 1 + }).pipe(Effect.andThen(Effect.never))) + } + } + } + }) + + assert.strictEqual(ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy, "generic") + const ref = yield* openWithRuntimeStrategy(machine, strategy, { authenticated: true }) + yield* waitForPath(ref, "Flow.Failed") + yield* ref.send(new Retry({})) + yield* waitForPath(ref, "Flow.MemberNavigating") + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + results.push({ + membershipAttempts, + navigationRuns, + path: (yield* ref.state).state.path + }) + yield* ref.stop + } + + assert.deepStrictEqual(results, [ + { membershipAttempts: 2, navigationRuns: 1, path: "Flow.MemberNavigating" }, + { membershipAttempts: 2, navigationRuns: 1, path: "Flow.MemberNavigating" } + ]) + }) as Effect.Effect) + it.effect("matches generic and indexed invoke failure traces", () => Effect.gen(function*() { class Loading extends Schema.TaggedClass("StrategyInvokeFailureLoading")("Loading", {}) {} diff --git a/packages/effect-machine/test/internal/machine/support/strategyDifferential.ts b/packages/effect-machine/test/internal/machine/support/strategyDifferential.ts index 37894ff..2ad0226 100644 --- a/packages/effect-machine/test/internal/machine/support/strategyDifferential.ts +++ b/packages/effect-machine/test/internal/machine/support/strategyDifferential.ts @@ -113,9 +113,10 @@ export const verifyPlannerStrategies: (options: { export const openWithRuntimeStrategy = ( machine: Machine.Machine.Any, - strategy: "generic" | "compiled" + strategy: "generic" | "compiled", + ...args: ReadonlyArray ): Effect.Effect, unknown> => - Process.startWithRuntimeStrategyForTesting(machine, strategy) as any + Process.startWithRuntimeStrategyForTesting(machine, strategy, ...args) as any export const prepareWithRuntimeStrategy = ( machine: Machine.Machine.Any, diff --git a/packages/effect-machine/test/machine/Choice.test.ts b/packages/effect-machine/test/machine/Choice.test.ts index 2d884e9..57cfed6 100644 --- a/packages/effect-machine/test/machine/Choice.test.ts +++ b/packages/effect-machine/test/machine/Choice.test.ts @@ -129,6 +129,73 @@ describe("Machine choice pseudo-states", () => { ]) })) + it.effect("decodes initial state constructions before resolving a choice", () => + Effect.gen(function*() { + class Root extends Schema.TaggedClass("ChoiceInputRoot")("ChoiceInputRoot", { + enabled: Schema.Boolean + }) {} + class NestedFlow extends Schema.TaggedClass("ChoiceInputFlow")("ChoiceInputFlow", { + score: Schema.Number + }) {} + const states = Machine.states({ + Root: { + schema: Root, + initial: "Flow", + states: { + Flow: { + schema: NestedFlow, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Approved, + Rejected + } + } + } + } + }) + let observedContext: { readonly enabled: boolean; readonly score: number } | undefined + const inputChoice = Machine.make({ + states: states.states, + events: Machine.events(), + input: Schema.Struct({ enabled: Schema.Boolean, score: Schema.Number }), + initial: (to) => + to.Root.initial.resolve(({ input, target }) => + target.from({ enabled: input.enabled }, (root) => + root.Flow.from({ score: input.score }, (flow) => flow.Routing())) + ) + }).handle({ + Root: { + states: { + Flow: { + states: { + Routing: { + choice: (to) => + to.branches({ + approved: { target: to.local.Approved() }, + rejected: { target: to.local.Rejected() } + }).resolve(({ ancestors, containingState, select }) => { + observedContext = { + enabled: ancestors.Root.enabled, + score: containingState.score + } + return ancestors.Root.enabled && containingState.score >= 70 + ? select.approved.decoded(new Approved({})) + : select.rejected.decoded(new Rejected({})) + }) + } + } + } + } + } + }) + + const plan = yield* Machine.planInitial(inputChoice, { enabled: true, score: 80 }) + assert.deepStrictEqual(observedContext, { enabled: true, score: 80 }) + assert.strictEqual(plan.state.state.state.path, "Root.Flow.Approved") + assert.strictEqual(plan.microsteps[0]?.transitions[0]?.branchKey, "approved") + })) + it.effect("targets a choice from an event and preserves that event", () => Effect.gen(function*() { const initial = yield* Machine.planInitial(machine)